Merge remote-tracking branch 'upstream/main' into fix/stale-interrupted-session-resume

# Conflicts:
#	src/resources/extensions/gsd/auto.ts
This commit is contained in:
Derek Pearson 2026-03-21 22:09:45 -04:00
commit 71c202c792
520 changed files with 95978 additions and 3520 deletions

View file

@ -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

View file

@ -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

View file

@ -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'

73
.github/workflows/pr-risk.yml vendored Normal file
View file

@ -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<<EOF" >> $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

4
.gitignore vendored
View file

@ -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

View file

@ -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)_

View file

@ -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

172
PLAN.md Normal file
View file

@ -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.

View file

@ -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.34v2.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.39v2.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.30v2.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 |

1020
docs/FILE-SYSTEM-MAP.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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**`<system-prompt>`, `<|im_start|>system`, `[SYSTEM]:`
- **Role/instruction overrides**`ignore previous instructions`, `you are now`, `new instructions:`
- **Hidden HTML directives**`<!-- PROMPT:`, `<!-- INSTRUCTION:`
- **Tool call injection**`<tool_call>`, `<function_call>`, `<invoke`
- **Invisible Unicode** — zero-width character sequences that hide directives
Content inside fenced code blocks (` ``` `) is excluded — patterns in code examples are expected and legitimate.
**False positives:** Add exceptions to `.prompt-injection-scanignore` using the same format as `.secretscanignore` (one pattern per line, `file:regex` for file-scoped exceptions).
### Gating Tests
The pipeline only triggers after `ci.yml` passes. Key gating tests include:

View file

@ -15,7 +15,7 @@
| `/gsd queue` | Queue and reorder future milestones (safe during auto mode) |
| `/gsd capture` | Fire-and-forget thought capture (works during auto mode) |
| `/gsd triage` | Manually trigger triage of pending captures |
| `/gsd forensics` | Post-mortem investigation of auto-mode failures — structured root-cause analysis with log inspection |
| `/gsd forensics` | Full-access GSD debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for auto-mode failures |
| `/gsd cleanup` | Clean up GSD state files and stale worktrees |
| `/gsd visualize` | Open workflow visualizer (progress, deps, metrics, timeline) |
| `/gsd export --html` | Generate self-contained HTML report for current or completed milestone |
@ -32,7 +32,7 @@
| `/gsd mode` | Switch workflow mode (solo/team) with coordinated defaults for milestone IDs, git commit behavior, and documentation |
| `/gsd config` | Re-run the provider setup wizard (LLM provider + tool keys) |
| `/gsd keys` | API key manager — list, add, remove, test, rotate, doctor |
| `/gsd doctor` | Runtime health checks (7 checks) with auto-fix for common state corruption issues |
| `/gsd doctor` | Runtime health checks with auto-fix — issues surface in real time across widget, visualizer, and HTML reports (v2.40) |
| `/gsd skill-health` | Skill lifecycle dashboard — usage stats, success rates, token trends, staleness warnings |
| `/gsd skill-health <name>` | Detailed view for a single skill |
| `/gsd skill-health --declining` | Show only skills flagged for declining performance |
@ -65,6 +65,15 @@
See [Parallel Orchestration](./parallel-orchestration.md) for full documentation.
## GitHub Sync (v2.39)
| Command | Description |
|---------|-------------|
| `/github-sync bootstrap` | Initial setup — creates GitHub Milestones, Issues, and draft PRs from current `.gsd/` state |
| `/github-sync status` | Show sync mapping counts (milestones, slices, tasks) |
Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed and authenticated. Sync mapping is persisted in `.gsd/.github-sync.json`.
## Git Commands
| Command | Description |

View file

@ -155,7 +155,8 @@ Recommended verification order:
| Variable | Default | Description |
|----------|---------|-------------|
| `GSD_HOME` | `~/.gsd` | Global GSD directory. All paths derive from this unless individually overridden. |
| `GSD_HOME` | `~/.gsd` | Global GSD directory. All paths derive from this unless individually overridden. Affects preferences, skills, sessions, and per-project state. (v2.39) |
| `GSD_PROJECT_ID` | (auto-hash) | Override the automatic project identity hash. Per-project state goes to `$GSD_HOME/projects/<GSD_PROJECT_ID>/` instead of the computed hash. Useful for CI/CD or sharing state across clones of the same repo. (v2.39) |
| `GSD_STATE_DIR` | `$GSD_HOME` | Per-project state root. Controls where `projects/<repo-hash>/` directories are created. Takes precedence over `GSD_HOME` for project state. |
| `GSD_CODING_AGENT_DIR` | `$GSD_HOME/agent` | Agent directory containing managed resources, extensions, and auth. Takes precedence over `GSD_HOME` for agent paths. |
@ -187,13 +188,35 @@ models:
### Custom Model Definitions (`models.json`)
Define custom models in `~/.gsd/agent/models.json`. This lets you add models not included in the default registry — useful for self-hosted endpoints, fine-tuned models, or new releases.
Define custom models and providers in `~/.gsd/agent/models.json`. This lets you add models not included in the default registry — useful for self-hosted endpoints (Ollama, vLLM, LM Studio), fine-tuned models, proxies, or new provider releases.
GSD resolves models.json with fallback logic:
1. `~/.gsd/agent/models.json` — primary (GSD)
2. `~/.pi/agent/models.json` — fallback (Pi)
3. If neither exists, creates `~/.gsd/agent/models.json`
**Quick example for local models (Ollama):**
```json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "llama3.1:8b" },
{ "id": "qwen2.5-coder:7b" }
]
}
}
}
```
The file reloads each time you open `/model` — no restart needed.
For full documentation including provider configuration, model overrides, OpenAI compatibility settings, and advanced examples, see the [Custom Models Guide](./custom-models.md).
**With fallbacks:**
```yaml
@ -429,6 +452,34 @@ git:
If `pr_target_branch` is not set, the PR targets the `main_branch` (or auto-detected main branch). PR creation failure is non-fatal — GSD logs and continues.
### `github` (v2.39)
GitHub sync configuration. When enabled, GSD auto-syncs milestones, slices, and tasks to GitHub Issues, PRs, and Milestones.
```yaml
github:
enabled: true
repo: "owner/repo" # auto-detected from git remote if omitted
labels: [gsd, auto-generated] # labels applied to created issues/PRs
project: "Project ID" # optional GitHub Project board
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | boolean | `false` | Enable GitHub sync |
| `repo` | string | (auto-detected) | GitHub repository in `owner/repo` format |
| `labels` | string[] | `[]` | Labels to apply to created issues and PRs |
| `project` | string | (none) | GitHub Project ID for project board integration |
**Requirements:**
- `gh` CLI installed and authenticated (`gh auth login`)
- Sync mapping is persisted in `.gsd/.github-sync.json`
- Rate-limit aware — skips sync when GitHub API rate limit is low
**Commands:**
- `/github-sync bootstrap` — initial setup and sync
- `/github-sync status` — show sync mapping counts
### `notifications`
Control what notifications GSD sends during auto mode:
@ -555,6 +606,32 @@ custom_instructions:
For project-specific knowledge (patterns, gotchas, lessons learned), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically. Add entries with `/gsd knowledge rule|pattern|lesson <description>`.
### `RUNTIME.md` — Runtime Context (v2.39)
Declare project-level runtime context in `.gsd/RUNTIME.md`. This file is inlined into task execution prompts, giving the agent accurate information about your runtime environment without relying on hallucinated paths or URLs.
**Location:** `.gsd/RUNTIME.md`
**Example:**
```markdown
# Runtime Context
## API Endpoints
- Main API: https://api.example.com
- Cache: redis://localhost:6379
## Environment Variables
- DEPLOYMENT_ENV: staging
- DB_POOL_SIZE: 20
## Local Services
- PostgreSQL: localhost:5432
- Redis: localhost:6379
```
Use this for information that the agent needs during execution but that doesn't belong in `DECISIONS.md` (architectural) or `KNOWLEDGE.md` (patterns/rules). Common examples: API base URLs, service ports, deployment targets, and environment-specific configuration.
### `dynamic_routing`
Complexity-based model routing. See [Dynamic Model Routing](./dynamic-model-routing.md).

335
docs/custom-models.md Normal file
View file

@ -0,0 +1,335 @@
# Custom Models
Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.gsd/agent/models.json`.
## Table of Contents
- [Minimal Example](#minimal-example)
- [Full Example](#full-example)
- [Supported APIs](#supported-apis)
- [Provider Configuration](#provider-configuration)
- [Model Configuration](#model-configuration)
- [Overriding Built-in Providers](#overriding-built-in-providers)
- [Per-model Overrides](#per-model-overrides)
- [OpenAI Compatibility](#openai-compatibility)
## Minimal Example
For local models (Ollama, LM Studio, vLLM), only `id` is required per model:
```json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "llama3.1:8b" },
{ "id": "qwen2.5-coder:7b" }
]
}
}
}
```
The `apiKey` is required but Ollama ignores it, so any value works.
Some OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so GSD sends the system prompt as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too.
You can set `compat` at the provider level to apply to all models, or at the model level to override a specific model. This commonly applies to Ollama, vLLM, SGLang, and similar OpenAI-compatible servers.
```json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false
},
"models": [
{
"id": "gpt-oss:20b",
"reasoning": true
}
]
}
}
}
```
## Full Example
Override defaults when you need specific values:
```json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{
"id": "llama3.1:8b",
"name": "Llama 3.1 8B (Local)",
"reasoning": false,
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 32000,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}
]
}
}
}
```
The file reloads each time you open `/model`. Edit during session; no restart needed.
## Supported APIs
| API | Description |
|-----|-------------|
| `openai-completions` | OpenAI Chat Completions (most compatible) |
| `openai-responses` | OpenAI Responses API |
| `anthropic-messages` | Anthropic Messages API |
| `google-generative-ai` | Google Generative AI |
Set `api` at provider level (default for all models) or model level (override per model).
## Provider Configuration
| Field | Description |
|-------|-------------|
| `baseUrl` | API endpoint URL |
| `api` | API type (see above) |
| `apiKey` | API key (see value resolution below) |
| `headers` | Custom headers (see value resolution below) |
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
| `models` | Array of model configurations |
| `modelOverrides` | Per-model overrides for built-in models on this provider |
### Value Resolution
The `apiKey` and `headers` fields support three formats:
- **Shell command:** `"!command"` executes and uses stdout
```json
"apiKey": "!security find-generic-password -ws 'anthropic'"
"apiKey": "!op read 'op://vault/item/credential'"
```
- **Environment variable:** Uses the value of the named variable
```json
"apiKey": "MY_API_KEY"
```
- **Literal value:** Used directly
```json
"apiKey": "sk-..."
```
### Custom Headers
```json
{
"providers": {
"custom-proxy": {
"baseUrl": "https://proxy.example.com/v1",
"apiKey": "MY_API_KEY",
"api": "anthropic-messages",
"headers": {
"x-portkey-api-key": "PORTKEY_API_KEY",
"x-secret": "!op read 'op://vault/item/secret'"
},
"models": [...]
}
}
}
```
## Model Configuration
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `id` | Yes | — | Model identifier (passed to the API) |
| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown in model details/status text. |
| `api` | No | provider's `api` | Override provider's API for this model |
| `reasoning` | No | `false` | Supports extended thinking |
| `input` | No | `["text"]` | Input types: `["text"]` or `["text", "image"]` |
| `contextWindow` | No | `128000` | Context window size in tokens |
| `maxTokens` | No | `16384` | Maximum output tokens |
| `cost` | No | all zeros | `{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}` (per million tokens) |
| `compat` | No | provider `compat` | OpenAI compatibility overrides. Merged with provider-level `compat` when both are set. |
Current behavior:
- `/model` and `--list-models` list entries by model `id`.
- The configured `name` is used for model matching and detail/status text.
## Overriding Built-in Providers
Route a built-in provider through a proxy without redefining models:
```json
{
"providers": {
"anthropic": {
"baseUrl": "https://my-proxy.example.com/v1"
}
}
}
```
All built-in Anthropic models remain available. Existing OAuth or API key auth continues to work.
To merge custom models into a built-in provider, include the `models` array:
```json
{
"providers": {
"anthropic": {
"baseUrl": "https://my-proxy.example.com/v1",
"apiKey": "ANTHROPIC_API_KEY",
"api": "anthropic-messages",
"models": [...]
}
}
}
```
Merge semantics:
- Built-in models are kept.
- Custom models are upserted by `id` within the provider.
- If a custom model `id` matches a built-in model `id`, the custom model replaces that built-in model.
- If a custom model `id` is new, it is added alongside built-in models.
## Per-model Overrides
Use `modelOverrides` to customize specific built-in models without replacing the provider's full model list.
```json
{
"providers": {
"openrouter": {
"modelOverrides": {
"anthropic/claude-sonnet-4": {
"name": "Claude Sonnet 4 (Bedrock Route)",
"compat": {
"openRouterRouting": {
"only": ["amazon-bedrock"]
}
}
}
}
}
}
}
```
`modelOverrides` supports these fields per model: `name`, `reasoning`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `headers`, `compat`.
Behavior notes:
- `modelOverrides` are applied to built-in provider models.
- Unknown model IDs are ignored.
- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`.
- If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry.
## OpenAI Compatibility
For providers with partial OpenAI compatibility, use the `compat` field.
- Provider-level `compat` applies defaults to all models under that provider.
- Model-level `compat` overrides provider-level values for that model.
```json
{
"providers": {
"local-llm": {
"baseUrl": "http://localhost:8080/v1",
"api": "openai-completions",
"compat": {
"supportsUsageInStreaming": false,
"maxTokensField": "max_tokens"
},
"models": [...]
}
}
}
```
| Field | Description |
|-------|-------------|
| `supportsStore` | Provider supports `store` field |
| `supportsDeveloperRole` | Use `developer` vs `system` role |
| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |
| `reasoningEffortMap` | Map GSD thinking levels to provider-specific `reasoning_effort` values |
| `supportsUsageInStreaming` | Supports `stream_options: { include_usage: true }` (default: `true`) |
| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |
| `requiresToolResultName` | Include `name` on tool result messages |
| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results |
| `requiresThinkingAsText` | Convert thinking blocks to plain text |
| `thinkingFormat` | Use `reasoning_effort`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
| `openRouterRouting` | OpenRouter routing config passed to OpenRouter for model/provider selection |
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
`qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`.
Example:
```json
{
"providers": {
"openrouter": {
"baseUrl": "https://openrouter.ai/api/v1",
"apiKey": "OPENROUTER_API_KEY",
"api": "openai-completions",
"models": [
{
"id": "openrouter/anthropic/claude-3.5-sonnet",
"name": "OpenRouter Claude 3.5 Sonnet",
"compat": {
"openRouterRouting": {
"order": ["anthropic"],
"fallbacks": ["openai"]
}
}
}
]
}
}
}
```
Vercel AI Gateway example:
```json
{
"providers": {
"vercel-ai-gateway": {
"baseUrl": "https://ai-gateway.vercel.sh/v1",
"apiKey": "AI_GATEWAY_API_KEY",
"api": "openai-completions",
"models": [
{
"id": "moonshotai/kimi-k2.5",
"name": "Kimi K2.5 (Fireworks via Vercel)",
"reasoning": true,
"input": ["text", "image"],
"cost": { "input": 0.6, "output": 3, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 262144,
"maxTokens": 262144,
"compat": {
"vercelGatewayRouting": {
"only": ["fireworks", "novita"],
"order": ["fireworks", "novita"]
}
}
}
]
}
}
}
```

View file

@ -47,7 +47,7 @@ Run `gsd` in any directory:
gsd
```
On first launch, GSD runs a setup wizard:
GSD displays a welcome screen showing your version, active model, and available tool keys. Then on first launch, it runs a setup wizard:
1. **LLM Provider** — select from 20+ providers (Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, Azure, and more). OAuth flows handle Claude Max and Copilot subscriptions automatically; otherwise paste an API key.
2. **Tool API Keys** (optional) — Brave Search, Context7, Jina, Slack, Discord. Press Enter to skip any.
@ -134,6 +134,8 @@ All state lives on disk in `.gsd/`:
PROJECT.md — what the project is right now
REQUIREMENTS.md — requirement contract (active/validated/deferred)
DECISIONS.md — append-only architectural decisions
KNOWLEDGE.md — cross-session rules, patterns, and lessons
RUNTIME.md — runtime context: API endpoints, env vars, services (v2.39)
STATE.md — quick-glance status
milestones/
M001/

View file

@ -120,6 +120,37 @@ rm -rf "$(dirname .gsd)/.gsd.lock"
**Fix:** GSD auto-resolves conflicts on `.gsd/` runtime files. For content conflicts in code files, the LLM is given an opportunity to resolve them via a fix-merge session. If that fails, manual resolution is needed.
### Pre-dispatch says the milestone integration branch no longer exists
**Symptoms:** Auto mode or `/gsd doctor` reports that a milestone recorded an integration branch that no longer exists in git.
**What it means:** The milestone's `.gsd/milestones/<MID>/<MID>-META.json` still points at the branch that was active when the milestone started, but that branch has since been renamed or deleted.
**Current behavior:**
- If GSD can deterministically recover to a safe branch, it no longer hard-stops auto mode.
- Safe fallbacks are:
- explicit `git.main_branch` when configured and present
- the repo's detected default integration branch (for example `main` or `master`)
- In that case `/gsd doctor` reports a warning and `/gsd doctor fix` rewrites the stale metadata to the effective branch.
- GSD still blocks when no safe fallback branch can be determined.
**Fix:**
- Run `/gsd doctor fix` to rewrite the stale milestone metadata automatically when the fallback is obvious.
- If GSD still blocks, recreate the missing branch or update your git preferences so `git.main_branch` points at a real branch.
### Transient `EBUSY` / `EPERM` / `EACCES` while writing `.gsd/` files
**Symptoms:** On Windows, auto mode or doctor occasionally fails while updating `.gsd/` files with errors like `EBUSY`, `EPERM`, or `EACCES`.
**Cause:** Antivirus, indexers, editors, or filesystem watchers can briefly lock the destination or temp file just as GSD performs the atomic rename.
**Current behavior:** GSD now retries those transient rename failures with a short bounded backoff before surfacing an error. The retry is intentionally limited so genuine filesystem problems still fail loudly instead of hanging forever.
**Fix:**
- Re-run the operation; most transient lock races clear quickly.
- If the error persists, close tools that may be holding the file open and then retry.
- If repeated failures continue, run `/gsd doctor` to confirm the repo state is still healthy and report the exact path + error code.
## MCP Client Issues
### `mcp_servers` shows no configured servers
@ -269,7 +300,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte
### "GSD database is not available"
**Symptoms:** `gsd_save_decision`, `gsd_update_requirement`, or `gsd_save_summary` fail with this error.
**Symptoms:** `gsd_decision_save` (or its alias `gsd_save_decision`), `gsd_requirement_update` (or `gsd_update_requirement`), or `gsd_summary_save` (or `gsd_save_summary`) fail with this error.
**Cause:** The SQLite database wasn't initialized. This happens in manual `/gsd` sessions (non-auto mode) on versions before v2.29.

45
docs/web-interface.md Normal file
View file

@ -0,0 +1,45 @@
# Web Interface
> Added in v2.41.0
GSD includes a browser-based web interface for project management, real-time progress monitoring, and multi-project support.
## Quick Start
```bash
pi --web
```
This starts a local web server and opens the GSD dashboard in your default browser.
## Features
- **Project management** — view milestones, slices, and tasks in a visual dashboard
- **Real-time progress** — server-sent events push status updates as auto-mode executes
- **Multi-project support** — manage multiple projects from a single browser tab via `?project=` URL parameter
- **Onboarding flow** — API key setup and provider configuration through the browser
- **Model selection** — switch models and providers from the web UI
## Architecture
The web interface is built with Next.js and communicates with the GSD backend via a bridge service. Each project gets its own bridge instance, providing isolation for concurrent sessions.
Key components:
- `ProjectBridgeService` — per-project command routing and SSE subscription
- `getProjectBridgeServiceForCwd()` — registry returning distinct instances per project path
- `resolveProjectCwd()` — reads `?project=` from request URL or falls back to `GSD_WEB_PROJECT_CWD`
## Configuration
The web server binds to `localhost` by default. No additional configuration is required.
### Environment Variables
| Variable | Description |
|----------|-------------|
| `GSD_WEB_PROJECT_CWD` | Default project path when `?project=` is not specified |
## Platform Notes
- **Windows**: The web build is skipped on Windows due to Next.js webpack EPERM issues with system directories. The CLI remains fully functional.
- **macOS/Linux**: Full support.

1
mintlify-docs/docs Submodule

@ -0,0 +1 @@
Subproject commit 5c549fdffb1eb56cacec19d33b8157a3b1e19d3c

View file

@ -8,7 +8,9 @@ repository.workspace = true
description = "N-API native addon for GSD — exposes high-performance Rust modules to Node.js"
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
test = false
doctest = false
[dependencies]
gsd-ast = { path = "../ast" }

View file

@ -6,6 +6,7 @@
//! ```
#![allow(clippy::needless_pass_by_value)]
#![cfg_attr(test, allow(dead_code))]
mod ast;
mod clipboard;

View file

@ -498,15 +498,91 @@ fn visible_width_u16(data: &[u16], tab_width: usize) -> usize {
// wrapTextWithAnsi
// ============================================================================
#[inline]
fn write_active_codes(state: &AnsiState, out: &mut Vec<u16>) {
if !state.is_empty() {
state.write_restore_u16(out);
// OSC 8 hyperlink state — tracks the active hyperlink URL (if any) so we can
// close it before a line break and re-open it on the next line.
#[derive(Clone, Default)]
struct Osc8State {
/// The full OSC 8 open sequence (e.g. ESC ]8;params;uri BEL), stored as
/// UTF-16 code units. Empty means no active hyperlink.
open_seq: Vec<u16>,
}
impl Osc8State {
fn new() -> Self {
Self { open_seq: Vec::new() }
}
fn is_active(&self) -> bool {
!self.open_seq.is_empty()
}
/// Write the OSC 8 close sequence: ESC ]8;; BEL
fn write_close(out: &mut Vec<u16>) {
out.extend_from_slice(&[ESC, b']' as u16, b'8' as u16, b';' as u16, b';' as u16, 0x07]);
}
/// Write the stored open sequence to re-open the hyperlink.
fn write_open(&self, out: &mut Vec<u16>) {
if self.is_active() {
out.extend_from_slice(&self.open_seq);
}
}
/// Parse an OSC sequence and update state. Returns true if it was an OSC 8.
fn update_from_osc(&mut self, seq: &[u16]) -> bool {
// OSC 8 format: ESC ]8; params ; uri BEL (or ST)
// Minimum: ESC ]8;; BEL = 6 code units
if seq.len() < 6 {
return false;
}
if seq[0] != ESC || seq[1] != b']' as u16 || seq[2] != b'8' as u16 || seq[3] != b';' as u16 {
return false;
}
// Find the second semicolon that separates params from URI
let mut second_semi = None;
for i in 4..seq.len() {
if seq[i] == b';' as u16 {
second_semi = Some(i);
break;
}
}
let second_semi = match second_semi {
Some(i) => i,
None => return false,
};
// URI is between second_semi+1 and the terminator (BEL or ST)
let uri_start = second_semi + 1;
// Terminator is at the end (BEL = 1 unit, ST = 2 units)
let terminator_len = if *seq.last().unwrap() == 0x07 { 1 } else { 2 };
let uri_end = seq.len() - terminator_len;
if uri_start >= uri_end {
// Empty URI = close hyperlink
self.open_seq.clear();
} else {
// Non-empty URI = open hyperlink
self.open_seq = seq.to_vec();
}
true
}
}
fn is_osc_u16(seq: &[u16]) -> bool {
seq.len() >= 3 && seq[0] == ESC && seq[1] == b']' as u16
}
#[inline]
fn write_line_end_reset(state: &AnsiState, out: &mut Vec<u16>) {
fn write_active_codes(state: &AnsiState, osc8: &Osc8State, out: &mut Vec<u16>) {
if !state.is_empty() {
state.write_restore_u16(out);
}
osc8.write_open(out);
}
#[inline]
fn write_line_end_reset(state: &AnsiState, osc8: &Osc8State, out: &mut Vec<u16>) {
if osc8.is_active() {
Osc8State::write_close(out);
}
let has_underline = state.attrs & ATTR_UNDERLINE != 0;
let has_strike = state.attrs & ATTR_STRIKE != 0;
if !has_underline && !has_strike {
@ -526,7 +602,7 @@ fn write_line_end_reset(state: &AnsiState, out: &mut Vec<u16>) {
out.push(b'm' as u16);
}
fn update_state_from_text(data: &[u16], state: &mut AnsiState) {
fn update_state_from_text(data: &[u16], state: &mut AnsiState, osc8: &mut Osc8State) {
let mut i = 0usize;
while i < data.len() {
if data[i] == ESC {
@ -534,6 +610,8 @@ fn update_state_from_text(data: &[u16], state: &mut AnsiState) {
let seq = &data[i..i + seq_len];
if is_sgr_u16(seq) {
state.apply_sgr_u16(&seq[2..seq_len - 1]);
} else if is_osc_u16(seq) {
osc8.update_from_osc(seq);
}
i += seq_len;
continue;
@ -619,10 +697,11 @@ fn break_long_word(
width: usize,
tab_width: usize,
state: &mut AnsiState,
osc8: &mut Osc8State,
) -> SmallVec<[Vec<u16>; 4]> {
let mut lines = SmallVec::<[Vec<u16>; 4]>::new();
let mut current_line = Vec::<u16>::new();
write_active_codes(state, &mut current_line);
write_active_codes(state, osc8, &mut current_line);
let mut current_width = 0usize;
let mut i = 0usize;
@ -633,6 +712,8 @@ fn break_long_word(
current_line.extend_from_slice(seq);
if is_sgr_u16(seq) {
state.apply_sgr_u16(&seq[2..seq_len - 1]);
} else if is_osc_u16(seq) {
osc8.update_from_osc(seq);
}
i += seq_len;
continue;
@ -653,10 +734,10 @@ fn break_long_word(
for &u in seg {
let gw = ascii_cell_width_u16(u, tab_width);
if current_width + gw > width {
write_line_end_reset(state, &mut current_line);
write_line_end_reset(state, osc8, &mut current_line);
lines.push(current_line);
current_line = Vec::new();
write_active_codes(state, &mut current_line);
write_active_codes(state, osc8, &mut current_line);
current_width = 0;
}
current_line.push(u);
@ -665,9 +746,9 @@ fn break_long_word(
} else {
let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| {
if current_width + gw > width {
write_line_end_reset(state, &mut current_line);
write_line_end_reset(state, osc8, &mut current_line);
lines.push(std::mem::take(&mut current_line));
write_active_codes(state, &mut current_line);
write_active_codes(state, osc8, &mut current_line);
current_width = 0;
}
current_line.extend_from_slice(gu16);
@ -698,6 +779,7 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
let mut current_line = Vec::<u16>::new();
let mut current_width = 0usize;
let mut state = AnsiState::new();
let mut osc8 = Osc8State::new();
for token in tokens {
let token_width = visible_width_u16(&token, tab_width);
@ -705,13 +787,13 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
if token_width > width && !is_whitespace {
if !current_line.is_empty() {
write_line_end_reset(&state, &mut current_line);
write_line_end_reset(&state, &osc8, &mut current_line);
wrapped.push(current_line);
current_line = Vec::new();
current_width = 0;
}
let mut broken = break_long_word(&token, width, tab_width, &mut state);
let mut broken = break_long_word(&token, width, tab_width, &mut state, &mut osc8);
if let Some(last) = broken.pop() {
wrapped.extend(broken);
current_line = last;
@ -724,11 +806,11 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
if total_needed > width && current_width > 0 {
let mut line_to_wrap = current_line;
trim_end_spaces_in_place(&mut line_to_wrap);
write_line_end_reset(&state, &mut line_to_wrap);
write_line_end_reset(&state, &osc8, &mut line_to_wrap);
wrapped.push(line_to_wrap);
current_line = Vec::new();
write_active_codes(&state, &mut current_line);
write_active_codes(&state, &osc8, &mut current_line);
if is_whitespace {
current_width = 0;
} else {
@ -740,7 +822,7 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
current_width += token_width;
}
update_state_from_text(&token, &mut state);
update_state_from_text(&token, &mut state, &mut osc8);
}
if !current_line.is_empty() {
@ -769,6 +851,7 @@ fn wrap_text_with_ansi_impl(
let mut result = SmallVec::<[Vec<u16>; 4]>::new();
let mut state = AnsiState::new();
let mut osc8 = Osc8State::new();
let mut line_start = 0usize;
for i in 0..=text.len() {
@ -776,13 +859,13 @@ fn wrap_text_with_ansi_impl(
let line = &text[line_start..i];
let mut line_with_prefix: Vec<u16> = Vec::new();
if !result.is_empty() {
write_active_codes(&state, &mut line_with_prefix);
write_active_codes(&state, &osc8, &mut line_with_prefix);
}
line_with_prefix.extend_from_slice(line);
let wrapped = wrap_single_line(&line_with_prefix, width, tab_width);
result.extend(wrapped);
update_state_from_text(line, &mut state);
update_state_from_text(line, &mut state, &mut osc8);
line_start = i + 1;
}
}
@ -1526,6 +1609,53 @@ mod tests {
assert_eq!(state.fg, 0x1000000 | (255 << 16) | (128 << 8) | 0);
}
#[test]
fn test_wrap_text_osc8_hyperlink_carried_across_lines() {
// OSC 8 hyperlink wrapping: \x1b]8;;https://example.com\x07click here please\x1b]8;;\x07
let url = "https://example.com";
let open = format!("\x1b]8;;{}\x07", url);
let close = "\x1b]8;;\x07";
let text = format!("{}click here please{}", open, close);
let data = to_u16(&text);
// Width 10 forces "click here please" (18 chars) to wrap
let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH);
assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len());
let first = String::from_utf16_lossy(&lines[0]);
let second = String::from_utf16_lossy(&lines[1]);
// First line should open the hyperlink and close it at the end
assert!(first.starts_with(&open), "First line should start with OSC 8 open: {:?}", first);
assert!(first.ends_with(close), "First line should end with OSC 8 close: {:?}", first);
// Second line should re-open the hyperlink
assert!(second.starts_with(&open), "Second line should re-open OSC 8: {:?}", second);
}
#[test]
fn test_wrap_text_osc8_long_url_break() {
// A long URL wrapped inside an OSC 8 hyperlink
let url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz";
let open = format!("\x1b]8;;{}\x07", url);
let close = "\x1b]8;;\x07";
let text = format!("{}{}{}", open, url, close);
let data = to_u16(&text);
let lines = wrap_text_with_ansi_impl(&data, 40, DEFAULT_TAB_WIDTH);
assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len());
for (i, line) in lines.iter().enumerate() {
let s = String::from_utf16_lossy(line);
// Every line except possibly the last (which has the close) should
// have the OSC 8 open sequence
assert!(s.contains(&open) || s.contains(close),
"Line {} should contain OSC 8 open or close: {:?}", i, s);
}
// Last line should contain the close
let last = String::from_utf16_lossy(lines.last().unwrap());
assert!(last.contains(close), "Last line should contain OSC 8 close: {:?}", last);
}
#[test]
fn test_clamp_u32_helper() {
assert_eq!(clamp_u32(0), 0);

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-darwin-arm64",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD native engine binary for macOS ARM64",
"os": [
"darwin"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-darwin-x64",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD native engine binary for macOS Intel",
"os": [
"darwin"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-linux-arm64-gnu",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD native engine binary for Linux ARM64 (glibc)",
"os": [
"linux"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-linux-x64-gnu",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD native engine binary for Linux x64 (glibc)",
"os": [
"linux"

View file

@ -1,6 +1,6 @@
{
"name": "@gsd-build/engine-win32-x64-msvc",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD native engine binary for Windows x64 (MSVC)",
"os": [
"win32"

6
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gsd-pi",
"version": "2.33.1",
"version": "2.40.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gsd-pi",
"version": "2.33.1",
"version": "2.40.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@ -9166,7 +9166,7 @@
},
"packages/pi-coding-agent": {
"name": "@gsd/pi-coding-agent",
"version": "2.33.1",
"version": "2.40.0",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4",

View file

@ -1,6 +1,6 @@
{
"name": "gsd-pi",
"version": "2.40.0",
"version": "2.41.0",
"description": "GSD — Get Shit Done coding agent",
"license": "MIT",
"repository": {
@ -22,11 +22,13 @@
},
"files": [
"dist",
"dist/web",
"packages",
"pkg",
"src/resources",
"scripts/postinstall.js",
"scripts/link-workspace-packages.cjs",
"scripts/ensure-workspace-builds.cjs",
"package.json",
"README.md"
],
@ -45,7 +47,9 @@
"build:pi-coding-agent": "npm run build -w @gsd/pi-coding-agent",
"build:native-pkg": "npm run build -w @gsd/native",
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
"build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
"build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs",
"stage:web-host": "node scripts/stage-web-standalone.cjs",
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
"copy-resources": "node scripts/copy-resources.cjs",
"copy-themes": "node scripts/copy-themes.cjs",
"copy-export-html": "node scripts/copy-export-html.cjs",
@ -66,7 +70,11 @@
"build:native": "node native/scripts/build.js",
"build:native:dev": "node native/scripts/build.js --dev",
"dev": "node scripts/dev.js",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/postinstall.js",
"gsd": "node scripts/dev-cli.js",
"gsd:web": "npm run build:pi && npm run copy-resources && node scripts/build-web-if-stale.cjs && node scripts/dev-cli.js --web",
"gsd:web:stop": "node scripts/dev-cli.js web stop",
"gsd:web:stop:all": "node scripts/dev-cli.js web stop all",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/ensure-workspace-builds.cjs && node scripts/postinstall.js",
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",

View file

@ -9,7 +9,7 @@
"build": "tsc -p tsconfig.json",
"build:native": "node ../../native/scripts/build.js",
"build:native:dev": "node ../../native/scripts/build.js --dev",
"test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs src/__tests__/image.test.mjs"
"test": "npm run build:native:dev && node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs src/__tests__/image.test.mjs"
},
"exports": {
".": {

View file

@ -130,6 +130,39 @@ describe("wrapTextWithAnsi", () => {
assert.equal(lines[0], "abcde");
assert.equal(lines[1], "fghij");
});
test("carries OSC 8 hyperlink across word-boundary wrap", () => {
const url = "https://example.com";
const open = `\x1b]8;;${url}\x07`;
const close = `\x1b]8;;\x07`;
const text = `${open}click here please${close}`;
const lines = native.wrapTextWithAnsi(text, 10);
assert.ok(lines.length >= 2, `Expected wrapping, got ${lines.length} lines`);
// First line should open the hyperlink and close it at the end
assert.ok(lines[0].startsWith(open), `First line should start with OSC 8 open: ${JSON.stringify(lines[0])}`);
assert.ok(lines[0].endsWith(close), `First line should end with OSC 8 close: ${JSON.stringify(lines[0])}`);
// Second line should re-open the hyperlink
assert.ok(lines[1].startsWith(open), `Second line should re-open OSC 8: ${JSON.stringify(lines[1])}`);
});
test("carries OSC 8 hyperlink across long-word break", () => {
const url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz";
const open = `\x1b]8;;${url}\x07`;
const close = `\x1b]8;;\x07`;
const text = `${open}${url}${close}`;
const lines = native.wrapTextWithAnsi(text, 40);
assert.ok(lines.length >= 2, `Expected wrapping, got ${lines.length} lines`);
// Every line except the last should end with close and re-open on next
for (let i = 0; i < lines.length - 1; i++) {
assert.ok(lines[i].includes(open), `Line ${i} should contain OSC 8 open`);
assert.ok(lines[i].endsWith(close), `Line ${i} should end with OSC 8 close`);
}
// Last line should contain close
assert.ok(lines[lines.length - 1].includes(close), `Last line should contain OSC 8 close`);
});
});
// ── truncateToWidth ────────────────────────────────────────────────────

View file

@ -0,0 +1,86 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { KnownProvider } from "./types.js";
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(): boolean {
if (cachedVertexAdcCredentialsExists !== null) {
return cachedVertexAdcCredentialsExists;
}
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
cachedVertexAdcCredentialsExists = gacPath
? existsSync(gacPath)
: existsSync(join(homedir(), ".config", "gcloud", "application_default_credentials.json"));
return cachedVertexAdcCredentialsExists;
}
/**
* Node-only env-key lookup for the standalone web host.
*
* This intentionally avoids the browser-safe dynamic-import pattern from the
* shared pi-ai runtime because the packaged Next standalone server turns that
* pattern into a failing "Cannot find module as expression is too dynamic"
* runtime branch.
*/
export function getEnvApiKey(provider: KnownProvider): string | undefined;
export function getEnvApiKey(provider: string): string | undefined;
export function getEnvApiKey(provider: string): string | undefined {
if (provider === "github-copilot") {
return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
}
if (provider === "anthropic") {
return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
}
if (provider === "google-vertex") {
const hasCredentials = hasVertexAdcCredentials();
const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT);
const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION;
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
}
}
if (
provider === "amazon-bedrock" &&
(
process.env.AWS_PROFILE ||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
process.env.AWS_BEARER_TOKEN_BEDROCK ||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI ||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE
)
) {
return "<authenticated>";
}
const envMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
google: "GEMINI_API_KEY",
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
huggingface: "HF_TOKEN",
opencode: "OPENCODE_API_KEY",
"opencode-go": "OPENCODE_API_KEY",
"kimi-coding": "KIMI_API_KEY",
"alibaba-coding-plan": "ALIBABA_API_KEY",
};
const envVar = envMap[provider];
return envVar ? process.env[envVar] : undefined;
}

View file

@ -0,0 +1,9 @@
export {
getOAuthProvider,
getOAuthProviders,
type OAuthAuthInfo,
type OAuthCredentials,
type OAuthLoginCallbacks,
type OAuthPrompt,
type OAuthProviderInterface,
} from "./oauth.js";

View file

@ -1,6 +1,6 @@
{
"name": "@gsd/pi-coding-agent",
"version": "2.40.0",
"version": "2.41.0",
"description": "Coding agent CLI (vendored from pi-mono)",
"type": "module",
"piConfig": {

View file

@ -108,8 +108,22 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {
}
/** Session-specific events that extend the core AgentEvent */
export type SessionStateChangeReason =
| "set_model"
| "set_thinking_level"
| "set_steering_mode"
| "set_follow_up_mode"
| "set_auto_compaction"
| "set_auto_retry"
| "abort_retry"
| "new_session"
| "switch_session"
| "set_session_name"
| "fork";
export type AgentSessionEvent =
| AgentEvent
| { type: "session_state_changed"; reason: SessionStateChangeReason }
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
| {
type: "auto_compaction_end";
@ -356,6 +370,10 @@ export class AgentSession {
}
}
private _emitSessionStateChanged(reason: SessionStateChangeReason): void {
this._emit({ type: "session_state_changed", reason });
}
// Track last assistant message for auto-compaction check
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
@ -1543,6 +1561,7 @@ export class AgentSession {
}
// Emit session event to custom tools
this._emitSessionStateChanged("new_session");
return true;
}
@ -1583,6 +1602,7 @@ export class AgentSession {
}
this.setThinkingLevel(thinkingLevel);
await this._emitModelSelect(model, previousModel, source);
this._emitSessionStateChanged("set_model");
}
/**
@ -1701,6 +1721,7 @@ export class AgentSession {
if (this.supportsThinking() || effectiveLevel !== "off") {
this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
}
this._emitSessionStateChanged("set_thinking_level");
}
}
@ -1782,6 +1803,7 @@ export class AgentSession {
setSteeringMode(mode: "all" | "one-at-a-time"): void {
this.agent.setSteeringMode(mode);
this.settingsManager.setSteeringMode(mode);
this._emitSessionStateChanged("set_steering_mode");
}
/**
@ -1791,6 +1813,7 @@ export class AgentSession {
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
this.agent.setFollowUpMode(mode);
this.settingsManager.setFollowUpMode(mode);
this._emitSessionStateChanged("set_follow_up_mode");
}
// =========================================================================
@ -1819,6 +1842,7 @@ export class AgentSession {
/** Toggle auto-compaction setting */
setAutoCompactionEnabled(enabled: boolean): void {
this._compactionOrchestrator.setAutoCompactionEnabled(enabled);
this._emitSessionStateChanged("set_auto_compaction");
}
/** Whether auto-compaction is enabled */
@ -2188,7 +2212,11 @@ export class AgentSession {
/** Cancel in-progress retry */
abortRetry(): void {
const hadRetry = this._retryHandler.isRetrying;
this._retryHandler.abortRetry();
if (hadRetry) {
this._emitSessionStateChanged("abort_retry");
}
}
/** Whether auto-retry is currently in progress */
@ -2204,6 +2232,7 @@ export class AgentSession {
/** Toggle auto-retry setting */
setAutoRetryEnabled(enabled: boolean): void {
this._retryHandler.setAutoRetryEnabled(enabled);
this._emitSessionStateChanged("set_auto_retry");
}
// =========================================================================
@ -2393,6 +2422,7 @@ export class AgentSession {
}
this._reconnectToAgent();
this._emitSessionStateChanged("switch_session");
return true;
}
@ -2401,6 +2431,7 @@ export class AgentSession {
*/
setSessionName(name: string): void {
this.sessionManager.appendSessionInfo(name);
this._emitSessionStateChanged("set_session_name");
}
/**
@ -2464,6 +2495,7 @@ export class AgentSession {
this.agent.replaceMessages(sessionContext.messages);
}
this._emitSessionStateChanged("fork");
return { selectedText, cancelled: false };
}

View file

@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
@ -59,7 +59,9 @@ describe("ModelDiscoveryCache — basic operations", () => {
cache.clear("openai");
assert.equal(cache.get("openai"), undefined);
assert.ok(cache.get("google"));
const googleEntry = cache.get("google");
assert.ok(googleEntry);
assert.equal(googleEntry.models[0].id, "gemini-pro");
});
it("clear without provider removes all entries", () => {

View file

@ -579,6 +579,46 @@ async function loadExtensionModule(extensionPath: string) {
return typeof factory !== "function" ? undefined : factory;
}
/**
* Check whether a module path belongs to a non-extension library that should
* be silently skipped rather than reported as an error.
*
* A directory is a non-extension library when its package.json has a "pi"
* manifest that declares no extensions (e.g. `"pi": {}`). This is the
* opt-out convention used by shared libraries like cmux that live inside
* the extensions/ directory but are not extensions themselves.
*
* This serves as a defense-in-depth check: even if the upstream discovery
* layers fail to filter out the library, the loader itself will not emit
* a spurious error.
*/
function isNonExtensionLibrary(resolvedPath: string): boolean {
// Walk up from the resolved file to find the nearest package.json
let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root;
while (dir !== root) {
const packageJsonPath = path.join(dir, "package.json");
if (fs.existsSync(packageJsonPath)) {
try {
const content = fs.readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content);
if (pkg.pi && typeof pkg.pi === "object") {
// Has a pi manifest — check if it declares any extensions
const extensions = pkg.pi.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return true;
}
}
} catch {
// Malformed package.json — not a known library
}
break;
}
dir = path.dirname(dir);
}
return false;
}
/**
* Create an Extension object with empty collections.
*/
@ -607,6 +647,12 @@ async function loadExtension(
try {
const factory = await loadExtensionModule(resolvedPath);
if (!factory) {
// Defense-in-depth: if the module is inside a directory that has
// explicitly opted out of extension loading via its pi manifest,
// silently skip it instead of reporting a spurious error.
if (isNonExtensionLibrary(resolvedPath)) {
return { extension: null, error: null };
}
logExtensionTiming(extensionPath, Date.now() - start, "failed");
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}

View file

@ -1,7 +1,7 @@
// GSD Login Dialog Component — OAuth login flow UI
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { getOAuthProviders } from "@gsd/pi-ai/oauth";
import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@gsd/pi-tui";
import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, truncateToWidth, type TUI } from "@gsd/pi-tui";
import { execFile } from "child_process";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -121,21 +121,25 @@ export class LoginDialogComponent extends Container implements Focusable {
showAuth(url: string, instructions?: string): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
// Truncate the visible URL text so it never wraps (which would break
// the OSC 8 hyperlink). The full URL is still the link target.
const maxUrlWidth = Math.max(20, this.tui.terminal.columns - 4);
const displayUrl = truncateToWidth(url, maxUrlWidth);
const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(urlLink, 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0));
if (instructions) {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
}
// Try to open browser — on Windows, `start` needs an empty title arg
// so it treats the URL as a target, not a window title
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
if (process.platform === "win32") {
execFile("cmd", ["/c", "start", "", url], () => {});
execFile("powershell", ["-c", `Start-Process '${url.replace(/'/g, "''")}'`], () => {});
} else {
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
execFile(openCmd, [url], () => {});

View file

@ -18,6 +18,9 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
showStatus: (message: string) => void;
showError: (message: string) => void;
updatePendingMessagesDisplay: () => void;
updateTerminalTitle: () => void;
updateEditorBorderColor: () => void;
pendingMessagesContainer: { clear: () => void };
}, event: InteractiveModeEvent): Promise<void> {
if (!host.isInitialized) {
await host.init();
@ -26,6 +29,35 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
host.footer.invalidate();
switch (event.type) {
case "session_state_changed":
switch (event.reason) {
case "new_session":
case "switch_session":
case "fork":
host.streamingComponent = undefined;
host.streamingMessage = undefined;
host.pendingTools.clear();
host.pendingMessagesContainer.clear();
host.compactionQueuedMessages = [];
host.rebuildChatFromMessages();
host.updatePendingMessagesDisplay();
host.updateTerminalTitle();
host.updateEditorBorderColor();
host.ui.requestRender();
return;
case "set_session_name":
host.updateTerminalTitle();
host.ui.requestRender();
return;
case "set_model":
case "set_thinking_level":
host.updateEditorBorderColor();
host.ui.requestRender();
return;
default:
host.ui.requestRender();
return;
}
case "agent_start":
if (host.retryEscapeHandler) {
host.defaultEditor.onEscape = host.retryEscapeHandler;

View file

@ -5,11 +5,13 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
getSlashCommandContext: () => any;
handleBashCommand: (command: string, excludeFromContext?: boolean) => Promise<void>;
showWarning: (message: string) => void;
showError: (message: string) => void;
updateEditorBorderColor: () => void;
isExtensionCommand: (text: string) => boolean;
queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void;
updatePendingMessagesDisplay: () => void;
flushPendingBashComponents: () => void;
options?: { submitPromptsDirectly?: boolean };
}): void {
host.defaultEditor.onSubmit = async (text: string) => {
text = text.trim();
@ -61,8 +63,24 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
}
host.flushPendingBashComponents();
host.onInputCallback?.(text);
if (host.onInputCallback) {
host.onInputCallback(text);
host.editor.addToHistory?.(text);
return;
}
if (host.options?.submitPromptsDirectly) {
host.editor.addToHistory?.(text);
try {
await host.session.prompt(text);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
host.showError(errorMessage);
}
return;
}
host.editor.addToHistory?.(text);
};
}

View file

@ -29,6 +29,7 @@ import {
matchesKey,
ProcessTerminal,
Spacer,
type Terminal as TuiTerminal,
Text,
TruncatedText,
TUI,
@ -144,6 +145,14 @@ export interface InteractiveModeOptions {
initialMessages?: string[];
/** Force verbose startup (overrides quietStartup setting) */
verbose?: boolean;
/** Override the terminal implementation used by the TUI. */
terminal?: TuiTerminal;
/** When false, reuse the session's existing extension bindings instead of rebinding them for TUI mode. */
bindExtensions?: boolean;
/** Submit editor prompts directly to AgentSession instead of using the interactive prompt loop. */
submitPromptsDirectly?: boolean;
/** Control what happens when the user requests shutdown from the TUI. */
shutdownBehavior?: "exit_process" | "stop_ui" | "ignore";
}
export class InteractiveMode {
@ -257,7 +266,7 @@ export class InteractiveMode {
) {
this.session = session;
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui = new TUI(options.terminal ?? new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.chatContainer = new Container();
@ -1086,89 +1095,91 @@ export class InteractiveMode {
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
if (this.options.bindExtensions !== false) {
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
}
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
return { cancelled: false };
return { cancelled: false };
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText && !this.editor.getText().trim()) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
switchSession: async (sessionPath) => {
await this.handleResumeSession(sessionPath);
return { cancelled: false };
},
reload: async () => {
await this.handleReloadCommand();
},
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
shutdownHandler: () => {
this.shutdownRequested = true;
if (!this.session.isStreaming) {
void this.shutdown();
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText && !this.editor.getText().trim()) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
switchSession: async (sessionPath) => {
await this.handleResumeSession(sessionPath);
return { cancelled: false };
},
reload: async () => {
await this.handleReloadCommand();
},
},
shutdownHandler: () => {
this.shutdownRequested = true;
if (!this.session.isStreaming) {
void this.shutdown();
}
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
});
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.setupAutocomplete();
@ -1496,6 +1507,10 @@ export class InteractiveMode {
return buildExtensionUIContext(this);
}
getExtensionUIContext(): ExtensionUIContext {
return this.createExtensionUIContext();
}
/**
* Show a selector for extensions.
*/
@ -2262,6 +2277,12 @@ export class InteractiveMode {
private isShuttingDown = false;
private async shutdown(): Promise<void> {
const shutdownBehavior = this.options.shutdownBehavior ?? "exit_process";
if (shutdownBehavior === "ignore") {
this.showStatus("Quit is unavailable in the browser-attached terminal");
return;
}
if (this.isShuttingDown) return;
this.isShuttingDown = true;
@ -2285,6 +2306,9 @@ export class InteractiveMode {
await this.ui.terminal.drainInput(1000);
this.stop();
if (shutdownBehavior === "stop_ui") {
return;
}
process.exit(0);
}
@ -3761,6 +3785,11 @@ export class InteractiveMode {
return result;
}
requestRender(force = false): void {
if (!this.isInitialized) return;
this.ui.requestRender(force);
}
stop(): void {
if (this.loadingAnimation) {
this.loadingAnimation.stop();

View file

@ -0,0 +1,103 @@
import type { Terminal } from "@gsd/pi-tui";
export interface RemoteTerminalOptions {
onWrite: (data: string) => void;
initialColumns?: number;
initialRows?: number;
}
/**
* Browser-backed terminal transport for the bridge-hosted native TUI.
* It implements the pi-tui Terminal contract but forwards output over the
* RPC bridge instead of writing to process stdout.
*/
export class RemoteTerminal implements Terminal {
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
constructor(private readonly options: RemoteTerminalOptions) {
this._columns = Math.max(1, options.initialColumns ?? 120);
this._rows = Math.max(1, options.initialRows ?? 30);
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
}
stop(): void {
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
async drainInput(): Promise<void> {
// Browser transport has no local stdin buffer to drain.
}
write(data: string): void {
if (!data) return;
this.options.onWrite(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
return false;
}
pushInput(data: string): void {
if (!data) return;
this.inputHandler?.(data);
}
resize(columns: number, rows: number): void {
const nextColumns = Math.max(1, Math.floor(columns));
const nextRows = Math.max(1, Math.floor(rows));
const changed = nextColumns !== this._columns || nextRows !== this._rows;
this._columns = nextColumns;
this._rows = nextRows;
if (changed) {
this.resizeHandler?.();
}
}
moveBy(lines: number): void {
if (lines > 0) {
this.write(`\x1b[${lines}B`);
} else if (lines < 0) {
this.write(`\x1b[${-lines}A`);
}
}
hideCursor(): void {
this.write("\x1b[?25l");
}
showCursor(): void {
this.write("\x1b[?25h");
}
clearLine(): void {
this.write("\x1b[K");
}
clearFromCursor(): void {
this.write("\x1b[J");
}
clearScreen(): void {
this.write("\x1b[2J\x1b[H");
}
setTitle(title: string): void {
this.write(`\x1b]0;${title}\x07`);
}
}

View file

@ -18,9 +18,11 @@ import type {
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js";
import { InteractiveMode } from "../interactive/interactive-mode.js";
import { type Theme, theme } from "../interactive/theme/theme.js";
import { createDefaultCommandContextActions } from "../shared/command-context-actions.js";
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
import { RemoteTerminal } from "./remote-terminal.js";
import type {
RpcCommand,
RpcExtensionUIRequest,
@ -72,6 +74,84 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Shutdown request flag
let shutdownRequested = false;
const embeddedTerminalEnabled = process.env.GSD_WEB_BRIDGE_TUI === "1";
const remoteTerminal = embeddedTerminalEnabled
? new RemoteTerminal({
onWrite: (data) => {
output({ type: "terminal_output", data });
},
})
: null;
let embeddedInteractiveMode: InteractiveMode | null = null;
let embeddedInteractiveInitPromise: Promise<void> | null = null;
const startupNotifications: Array<{ message: string; type?: "info" | "warning" | "error" | "success" }> = [];
const statusState = new Map<string, string | undefined>();
const widgetState = new Map<string, { content: unknown; options?: ExtensionWidgetOptions }>();
let footerFactory: Parameters<ExtensionUIContext["setFooter"]>[0] | undefined;
let headerFactory: Parameters<ExtensionUIContext["setHeader"]>[0] | undefined;
let workingMessageState: string | undefined;
let titleState: string | undefined;
let editorTextState: string | undefined;
const withEmbeddedUiContext = async (apply: (ui: ExtensionUIContext) => void | Promise<void>): Promise<void> => {
if (!embeddedInteractiveMode) {
return;
}
await apply(embeddedInteractiveMode.getExtensionUIContext());
};
const replayEmbeddedUiState = async (interactiveMode: InteractiveMode): Promise<void> => {
const ui = interactiveMode.getExtensionUIContext();
ui.setHeader(headerFactory);
ui.setFooter(footerFactory);
for (const [key, text] of statusState.entries()) {
ui.setStatus(key, text);
}
for (const [key, widget] of widgetState.entries()) {
ui.setWidget(key, widget.content as any, widget.options);
}
ui.setWorkingMessage(workingMessageState);
if (titleState) {
ui.setTitle(titleState);
}
if (editorTextState !== undefined) {
ui.setEditorText(editorTextState);
}
for (const { message, type } of startupNotifications) {
ui.notify(message, type);
}
};
const ensureEmbeddedInteractiveMode = async (): Promise<InteractiveMode> => {
if (!embeddedTerminalEnabled || !remoteTerminal) {
throw new Error("Embedded terminal is not enabled for this RPC host");
}
if (embeddedInteractiveMode) {
return embeddedInteractiveMode;
}
if (!embeddedInteractiveInitPromise) {
embeddedInteractiveMode = new InteractiveMode(session, {
terminal: remoteTerminal,
bindExtensions: false,
submitPromptsDirectly: true,
shutdownBehavior: "ignore",
});
embeddedInteractiveInitPromise = embeddedInteractiveMode.init().then(async () => {
await replayEmbeddedUiState(embeddedInteractiveMode!);
}).catch((error) => {
embeddedInteractiveMode = null;
throw error;
}).finally(() => {
embeddedInteractiveInitPromise = null;
});
}
await embeddedInteractiveInitPromise;
return embeddedInteractiveMode!;
};
/** Helper for dialog methods with signal/timeout support */
function createDialogPromise<T>(
opts: ExtensionUIDialogOptions | undefined,
@ -135,6 +215,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
),
notify(message: string, type?: "info" | "warning" | "error" | "success"): void {
startupNotifications.push({ message, type });
if (startupNotifications.length > 20) {
startupNotifications.splice(0, startupNotifications.length - 20);
}
// Fire and forget - no response needed
output({
type: "extension_ui_request",
@ -143,6 +227,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
message,
notifyType: type,
} as RpcExtensionUIRequest);
void withEmbeddedUiContext((ui) => {
ui.notify(message, type);
});
},
onTerminalInput(): () => void {
@ -151,6 +238,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
setStatus(key: string, text: string | undefined): void {
statusState.set(key, text);
// Fire and forget - no response needed
output({
type: "extension_ui_request",
@ -159,13 +247,20 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
statusKey: key,
statusText: text,
} as RpcExtensionUIRequest);
void withEmbeddedUiContext((ui) => {
ui.setStatus(key, text);
});
},
setWorkingMessage(_message?: string): void {
// Working message not supported in RPC mode - requires TUI loader access
setWorkingMessage(message?: string): void {
workingMessageState = message;
void withEmbeddedUiContext((ui) => {
ui.setWorkingMessage(message);
});
},
setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
widgetState.set(key, { content, options });
if (content === undefined || Array.isArray(content)) {
output({
type: "extension_ui_request",
@ -187,17 +282,27 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
widgetPlacement: options?.placement,
} as RpcExtensionUIRequest);
}
void withEmbeddedUiContext((ui) => {
ui.setWidget(key, content as any, options);
});
},
setFooter(_factory: unknown): void {
// Custom footer not supported in RPC mode - requires TUI access
setFooter(factory: Parameters<ExtensionUIContext["setFooter"]>[0]): void {
footerFactory = factory;
void withEmbeddedUiContext((ui) => {
ui.setFooter(factory);
});
},
setHeader(_factory: unknown): void {
// Custom header not supported in RPC mode - requires TUI access
setHeader(factory: Parameters<ExtensionUIContext["setHeader"]>[0]): void {
headerFactory = factory;
void withEmbeddedUiContext((ui) => {
ui.setHeader(factory);
});
},
setTitle(title: string): void {
titleState = title;
// Fire and forget - host can implement terminal title control
output({
type: "extension_ui_request",
@ -205,6 +310,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
method: "setTitle",
title,
} as RpcExtensionUIRequest);
void withEmbeddedUiContext((ui) => {
ui.setTitle(title);
});
},
async custom() {
@ -218,6 +326,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
setEditorText(text: string): void {
editorTextState = text;
// Fire and forget - host can implement editor control
output({
type: "extension_ui_request",
@ -225,6 +334,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
method: "set_editor_text",
text,
} as RpcExtensionUIRequest);
void withEmbeddedUiContext((ui) => {
ui.setEditorText(text);
});
},
getEditorText(): string {
@ -283,8 +395,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
});
// Set up extensions with RPC-based UI context
await session.bindExtensions({
// Set up extensions with RPC-based UI context.
// Do not block the initial RPC handshake on extension session_start hooks:
// browser boot only needs get_state, and several startup-only notifications
// (MCP availability, web-search status, etc.) can complete in the background.
// Track readiness so consumers can know when extension commands are available.
let extensionsReady = false;
const extensionsReadyPromise = session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: createDefaultCommandContextActions(session),
shutdownHandler: () => {
@ -293,7 +410,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
onError: (err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
}).then(() => {
extensionsReady = true;
output({ type: "extensions_ready" });
}).catch((error) => {
extensionsReady = true; // Mark ready even on failure so consumers don't wait forever
output({
type: "extension_error",
event: "session_start",
error: error instanceof Error ? error.message : String(error),
});
});
void extensionsReadyPromise;
// Output all agent events as JSON
session.subscribe((event) => {
@ -360,8 +488,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
sessionId: session.sessionId,
sessionName: session.sessionName,
autoCompactionEnabled: session.autoCompactionEnabled,
autoRetryEnabled: session.autoRetryEnabled,
retryInProgress: session.isRetrying,
retryAttempt: session.retryAttempt,
messageCount: session.messages.length,
pendingMessageCount: session.pendingMessageCount,
extensionsReady,
};
return success(id, "get_state", state);
}
@ -559,6 +691,24 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "get_commands", { commands });
}
case "terminal_input": {
await ensureEmbeddedInteractiveMode();
remoteTerminal!.pushInput(command.data);
return success(id, "terminal_input");
}
case "terminal_resize": {
await ensureEmbeddedInteractiveMode();
remoteTerminal!.resize(command.cols, command.rows);
return success(id, "terminal_resize");
}
case "terminal_redraw": {
const interactiveMode = await ensureEmbeddedInteractiveMode();
interactiveMode.requestRender(true);
return success(id, "terminal_redraw");
}
default: {
const unknownCommand = command as { type: string };
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
@ -580,6 +730,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
await currentRunner.emit({ type: "session_shutdown" });
}
embeddedInteractiveMode?.stop();
detachInput();
process.stdin.pause();
process.exit(0);

View file

@ -64,7 +64,12 @@ export type RpcCommand =
| { id?: string; type: "get_messages" }
// Commands (available for invocation via prompt)
| { id?: string; type: "get_commands" };
| { id?: string; type: "get_commands" }
// Bridge-hosted native terminal
| { id?: string; type: "terminal_input"; data: string }
| { id?: string; type: "terminal_resize"; cols: number; rows: number }
| { id?: string; type: "terminal_redraw" };
// ============================================================================
// RPC Slash Command (for get_commands response)
@ -99,8 +104,13 @@ export interface RpcSessionState {
sessionId: string;
sessionName?: string;
autoCompactionEnabled: boolean;
autoRetryEnabled: boolean;
retryInProgress: boolean;
retryAttempt: number;
messageCount: number;
pendingMessageCount: number;
/** Whether extension loading has completed. Commands from `get_commands` may be incomplete until true. */
extensionsReady: boolean;
}
// ============================================================================
@ -201,6 +211,11 @@ export type RpcResponse =
data: { commands: RpcSlashCommand[] };
}
// Bridge-hosted native terminal
| { id?: string; type: "response"; command: "terminal_input"; success: true }
| { id?: string; type: "response"; command: "terminal_resize"; success: true }
| { id?: string; type: "response"; command: "terminal_redraw"; success: true }
// Error response (any command can fail)
| { id?: string; type: "response"; command: string; success: false; error: string };

View file

@ -8,7 +8,8 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {

View file

@ -119,6 +119,21 @@ describe("CombinedAutocompleteProvider — @ file prefix extraction", () => {
const result = provider.getSuggestions(["check @nonexistent_xyz"], 0, 22);
assert.ok(result === null || result.items.length >= 0);
});
it("returns null for bare @ with no query to avoid full tree walk (#1824)", () => {
const provider = makeProvider([], process.cwd());
// A bare "@" produces an empty rawPrefix after stripping the "@".
// This must return null to avoid a synchronous full filesystem walk
// via the native fuzzyFind addon, which freezes the TUI on large repos.
const result = provider.getSuggestions(["@"], 0, 1);
assert.equal(result, null, "bare @ should not trigger fuzzy file search");
});
it("returns null for @ after space with no query (#1824)", () => {
const provider = makeProvider([], process.cwd());
const result = provider.getSuggestions(["look at @"], 0, 9);
assert.equal(result, null, "@ after space with no query should not trigger fuzzy file search");
});
});
describe("CombinedAutocompleteProvider — applyCompletion", () => {

View file

@ -573,9 +573,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {
try {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const searchPath = scopedQuery?.baseDir ?? this.basePath;
const searchQuery = scopedQuery?.query ?? query;
// Skip the expensive filesystem walk when the query is empty.
// An empty query (bare "@" with nothing typed yet) would walk the
// entire directory tree via the native fuzzyFind call, blocking
// the event loop and freezing the TUI on large repos.
if (searchQuery.length === 0 && !scopedQuery) {
return [];
}
const searchPath = scopedQuery?.baseDir ?? this.basePath;
const result = fuzzyFind({
query: searchQuery,
path: searchPath,

View file

@ -967,13 +967,19 @@ export class Editor implements Component, Focusable {
this.tryTriggerAutocomplete();
}
// Auto-trigger for "@" file reference (fuzzy search)
// Debounced: the bare "@" triggers a fuzzyFind call that does a
// synchronous filesystem walk via the native addon. Firing it
// immediately on the keystroke blocks the event loop and freezes
// the TUI on large repos. Debouncing lets subsequent keystrokes
// cancel the pending search so the walk only runs once the user
// pauses typing.
else if (char === "@") {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Only trigger if @ is after whitespace or at start of line
const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
this.tryTriggerAutocomplete();
this.debouncedTriggerAutocomplete();
}
}
// Also auto-trigger when typing letters in a slash command context
@ -2116,6 +2122,15 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
private applyAutocompleteSuggestions(): void {
if (!this.autocompleteProvider) return;
// Deduplicate: skip the (potentially expensive synchronous) lookup
// when the prefix hasn't changed since the last call.
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
if (this.lastAutocompleteLookupPrefix !== null && this.lastAutocompleteLookupPrefix === textBeforeCursor) {
return;
}
this.lastAutocompleteLookupPrefix = textBeforeCursor;
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,

View file

@ -121,7 +121,7 @@ export class Markdown implements Component {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
for (let j = 0; j < tokenLines.length; j++) renderedLines.push(tokenLines[j]);
}
// Wrap lines (NO padding, NO background yet)
@ -308,7 +308,8 @@ export class Markdown implements Component {
}
case "code": {
lines.push(...this.renderCodeBlock(token.text, token.lang));
const codeBlockLines = this.renderCodeBlock(token.text, token.lang);
for (let j = 0; j < codeBlockLines.length; j++) lines.push(codeBlockLines[j]);
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after code blocks (unless space token follows)
}
@ -317,7 +318,7 @@ export class Markdown implements Component {
case "list": {
const listLines = this.renderList(token as any, 0, styleContext);
lines.push(...listLines);
for (let j = 0; j < listLines.length; j++) lines.push(listLines[j]);
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
@ -325,7 +326,7 @@ export class Markdown implements Component {
case "table": {
const tableLines = this.renderTable(token as any, width, styleContext);
lines.push(...tableLines);
for (let j = 0; j < tableLines.length; j++) lines.push(tableLines[j]);
break;
}
@ -561,7 +562,7 @@ export class Markdown implements Component {
// Nested list - render with one additional indent level
// These lines will have their own indent, so we just add them as-is
const nestedLines = this.renderList(token as any, parentDepth + 1, styleContext);
lines.push(...nestedLines);
for (let j = 0; j < nestedLines.length; j++) lines.push(nestedLines[j]);
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
@ -575,7 +576,8 @@ export class Markdown implements Component {
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
lines.push(...this.renderCodeBlock(token.text, token.lang));
const codeLines = this.renderCodeBlock(token.text, token.lang);
for (let j = 0; j < codeLines.length; j++) lines.push(codeLines[j]);
} else {
// Other token types - try to render as inline
const text = this.renderInlineTokens([token], styleContext);

View file

@ -91,7 +91,8 @@ export class SettingsList implements Component {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
const rendered = this.searchInput.render(width);
for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
lines.push("");
}

View file

@ -191,7 +191,8 @@ export class Container implements Component {
render(width: number): string[] {
const lines: string[] = [];
for (const child of this.children) {
lines.push(...child.render(width));
const rendered = child.render(width);
for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
}
return lines;
}
@ -811,7 +812,7 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
if (process.env.PI_TUI_DEBUG === "1") {
const debugDir = "/tmp/tui";
const debugDir = path.join(os.tmpdir(), "tui");
fs.mkdirSync(debugDir, { recursive: true });
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
const debugData = [

View file

@ -1,6 +1,6 @@
{
"name": "@glittercowboy/gsd",
"version": "2.40.0",
"version": "2.41.0",
"piConfig": {
"name": "gsd",
"configDir": ".gsd"

View file

@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Rebuild the Next.js web host only when web source files are newer than the
* staged standalone build. Skips the build when nothing has changed.
*
* Also self-heals a missing/incomplete web dependency install so `npm run gsd:web`
* doesn't fail with bare `next` command-not-found errors.
*
* Exit codes:
* 0 build was up-to-date or successfully rebuilt
* 1 build failed
*/
'use strict'
const { execSync } = require('node:child_process')
const { existsSync, readdirSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
// Skip on Windows — Next.js webpack build hits EPERM scanning system dirs
if (process.platform === 'win32') {
console.log('[gsd] Web build skipped on Windows.')
process.exit(0)
}
const root = resolve(__dirname, '..')
const webRoot = join(root, 'web')
// Also watch src/ because api routes import directly from src/web/* and src/resources/*
const srcRoot = join(root, 'src')
const stagedSentinel = join(root, 'dist', 'web', 'standalone', 'server.js')
// Directories inside web/ that are not source and should be ignored for
// staleness comparison.
const IGNORED_DIRS = new Set(['node_modules', '.next', '.turbo', 'dist', 'out', '.cache'])
/**
* Walk a directory tree, yield the mtime of every file, skipping ignored dirs.
* Returns the maximum mtime found (ms since epoch), or 0 if nothing found.
*/
function newestMtime(dir) {
let max = 0
let stack = [dir]
while (stack.length > 0) {
const current = stack.pop()
let entries
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) {
stack.push(join(current, entry.name))
}
continue
}
try {
const mt = statSync(join(current, entry.name)).mtimeMs
if (mt > max) max = mt
} catch {
// skip unreadable files
}
}
}
return max
}
function sentinelMtime() {
try {
return statSync(stagedSentinel).mtimeMs
} catch {
return 0
}
}
function hasWebBuildDependencies() {
return existsSync(join(webRoot, 'node_modules', '.bin', 'next'))
}
function ensureWebBuildDependencies() {
if (hasWebBuildDependencies()) {
return
}
console.log('[gsd] Web build dependencies are missing or incomplete — running npm --prefix web ci...')
execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' })
}
const sourceMtime = Math.max(newestMtime(webRoot), newestMtime(srcRoot))
const builtMtime = sentinelMtime()
if (builtMtime > 0 && builtMtime >= sourceMtime) {
console.log('[gsd] Web build is up-to-date, skipping rebuild.')
process.exit(0)
}
if (builtMtime === 0) {
console.log('[gsd] No staged web build found — building now...')
} else {
console.log('[gsd] Web/src source has changed since last build — rebuilding...')
}
try {
ensureWebBuildDependencies()
execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' })
} catch (err) {
console.error('[gsd] Web build failed:', err.message)
process.exit(1)
}

33
scripts/dev-cli.js Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
const srcLoaderPath = resolve(root, 'src', 'loader.ts')
const resolveTsPath = resolve(root, 'src', 'resources', 'extensions', 'gsd', 'tests', 'resolve-ts.mjs')
const child = spawn(
process.execPath,
['--import', resolveTsPath, '--experimental-strip-types', srcLoaderPath, ...process.argv.slice(2)],
{
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
},
)
child.on('error', (error) => {
console.error(`[gsd] Failed to launch local dev CLI: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 0)
})

View file

@ -0,0 +1,209 @@
#!/usr/bin/env bash
# Scan markdown documentation for prompt injection patterns.
# Designed to catch hidden directives, role overrides, and system prompt
# markers that could influence LLM behavior when docs are ingested as context.
#
# Usage:
# bash scripts/docs-prompt-injection-scan.sh # scan staged .md files
# bash scripts/docs-prompt-injection-scan.sh --diff origin/main # scan changed .md files vs branch
# bash scripts/docs-prompt-injection-scan.sh --file README.md # scan a single file
set -euo pipefail
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
IGNOREFILE=".prompt-injection-scanignore"
EXIT_CODE=0
FINDINGS=0
# ── Patterns ──────────────────────────────────────────────────────────
# Format: "Label:::flags:::regex"
# Flags: i = case-insensitive
PATTERNS=(
# System prompt markers
"System prompt marker:::i:::<system-prompt>"
"System prompt marker:::i:::<\|im_start\|>system"
"System prompt marker:::i:::\[SYSTEM\][[:space:]]*:"
# Role injection / override
"Role injection:::i:::you are now [a-z]"
"Instruction override:::i:::ignore (all )?previous instructions"
"Instruction override:::i:::ignore (all )?prior instructions"
"Instruction override:::i:::disregard (all )?(above|previous|prior)"
"Instruction override:::i:::forget (all )?(above|previous|prior) (instructions|context|rules)"
"Instruction override:::i:::new instructions:"
"Instruction override:::i:::override (all )?instructions"
"Instruction override:::i:::your new role is"
"Instruction override:::i:::from now on,? (you (are|will|must|should)|act as)"
# Hidden HTML directives
"Hidden HTML directive::::::<!--[[:space:]]*(PROMPT|INSTRUCTION|SYSTEM|OVERRIDE|INJECT)[[:space:]]*:"
"Hidden HTML directive::::::<!--[[:space:]]*(ignore|disregard|forget|override)"
# Tool / function call injection
"Tool call injection::::::(<tool_call>|<function_call>|<tool_use>)"
"Tool call injection::::::(<invoke|<function_calls>)"
# Encoded payload markers
"Encoded payload:::i:::(eval|exec|decode)\((base64|atob|btoa)"
# Invisible Unicode tricks (zero-width chars used to hide directives)
# Match specific zero-width codepoints: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+FEFF (BOM)
# Use Perl-compatible Unicode escapes to avoid matching em-dash (U+2014) and similar
"Invisible Unicode:::P:::\\x{200B}|\\x{200C}|\\x{200D}|\\x{FEFF}"
)
# ── Helpers ───────────────────────────────────────────────────────────
load_ignore_patterns() {
local ignore_patterns=()
if [[ -f "$IGNOREFILE" ]]; then
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^# ]] && continue
ignore_patterns+=("$line")
done < "$IGNOREFILE"
fi
echo "${ignore_patterns[@]+"${ignore_patterns[@]}"}"
}
is_ignored() {
local file="$1" line_content="$2"
local ignore_patterns
read -ra ignore_patterns <<< "$(load_ignore_patterns)"
for pattern in "${ignore_patterns[@]+"${ignore_patterns[@]}"}"; do
if [[ "$pattern" == *:* ]]; then
local ignore_file="${pattern%%:*}"
local ignore_regex="${pattern#*:}"
if [[ "$file" == $ignore_file ]] && echo "$line_content" | grep -qiE "$ignore_regex" 2>/dev/null; then
return 0
fi
else
if echo "$line_content" | grep -qiE "$pattern" 2>/dev/null; then
return 0
fi
fi
done
return 1
}
# Strip fenced code blocks and inline code from content so we don't flag
# examples/docs. Returns only the prose portions of the markdown.
strip_code_blocks() {
awk '
/^```/ { in_code = !in_code; print ""; next }
in_code { print ""; next }
{
# Replace inline backtick spans with empty string
gsub(/`[^`]+`/, "")
print
}
'
}
get_files() {
if [[ "${1:-}" == "--diff" ]]; then
local ref="${2:-HEAD}"
git diff --name-only --diff-filter=ACMR "$ref" 2>/dev/null | grep -E '\.(md|markdown)$' || true
elif [[ "${1:-}" == "--file" ]]; then
echo "${2:-}"
else
git diff --cached --name-only --diff-filter=ACMR 2>/dev/null | grep -E '\.(md|markdown)$' || true
fi
}
get_content() {
local file="$1"
if [[ "${SCAN_MODE:-staged}" == "staged" ]]; then
git show ":$file" 2>/dev/null || cat "$file" 2>/dev/null || true
else
cat "$file" 2>/dev/null || true
fi
}
# ── Parse arguments ───────────────────────────────────────────────────
SCAN_MODE="staged"
FILES_ARG=()
while [[ $# -gt 0 ]]; do
case "$1" in
--diff) SCAN_MODE="diff"; FILES_ARG=("--diff" "${2:-HEAD}"); shift 2 ;;
--file) SCAN_MODE="file"; FILES_ARG=("--file" "$2"); shift 2 ;;
*) shift ;;
esac
done
FILES=$(get_files "${FILES_ARG[@]+"${FILES_ARG[@]}"}")
if [[ -z "$FILES" ]]; then
echo "prompt-injection-scan: no documentation files to scan"
exit 0
fi
# ── Scan ──────────────────────────────────────────────────────────────
while IFS= read -r file; do
[[ -z "$file" ]] && continue
raw_content=$(get_content "$file")
[[ -z "$raw_content" ]] && continue
# Strip code blocks so we only scan prose
content=$(echo "$raw_content" | strip_code_blocks)
for entry in "${PATTERNS[@]}"; do
label="${entry%%:::*}"
rest="${entry#*:::}"
flags="${rest%%:::*}"
regex="${rest#*:::}"
if [[ "$flags" == *P* ]]; then
grep_flags="-nP"
else
grep_flags="-nE"
fi
if [[ "$flags" == *i* ]]; then
grep_flags="${grep_flags}i"
fi
matches=$(echo "$content" | grep $grep_flags -e "$regex" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
while IFS= read -r match_line; do
[[ -z "$match_line" ]] && continue
line_num="${match_line%%:*}"
line_content="${match_line#*:}"
if is_ignored "$file" "$line_content"; then
continue
fi
echo -e "${RED}[PROMPT INJECTION]${NC} ${YELLOW}${label}${NC}"
echo -e " File: ${CYAN}${file}:${line_num}${NC}"
echo " Line: $(echo "$line_content" | head -c 120)..."
echo ""
FINDINGS=$((FINDINGS + 1))
EXIT_CODE=1
done <<< "$matches"
fi
done
done <<< "$FILES"
# ── Report ────────────────────────────────────────────────────────────
if [[ $FINDINGS -gt 0 ]]; then
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${RED}Found $FINDINGS potential prompt injection(s) in docs.${NC}"
echo -e "${RED}Review flagged lines and remove or move to code blocks.${NC}"
echo -e "${RED}Add exceptions to .prompt-injection-scanignore if these${NC}"
echo -e "${RED}are false positives.${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
else
echo "prompt-injection-scan: no prompt injection detected ✓"
fi
exit $EXIT_CODE

View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* ensure-workspace-builds.cjs
*
* Checks whether workspace packages have been compiled (dist/ exists with
* index.js). If any are missing, runs the build for those packages.
*
* Designed for the postinstall hook so that `npm install` in a fresh clone
* produces a working runtime without a manual `npm run build` step.
*
* Skipped in CI (where the full build pipeline handles this) and when
* installing as an end-user dependency (no packages/ directory).
*/
const { existsSync } = require('fs')
const { resolve, join } = require('path')
const { execSync } = require('child_process')
const root = resolve(__dirname, '..')
const packagesDir = join(root, 'packages')
// Skip if packages/ doesn't exist (published tarball / end-user install)
if (!existsSync(packagesDir)) process.exit(0)
// Skip in CI — the pipeline runs `npm run build` explicitly
if (process.env.CI === 'true' || process.env.CI === '1') process.exit(0)
// Workspace packages that need dist/index.js at runtime.
// Order matters: dependencies must build before dependents.
const WORKSPACE_PACKAGES = [
'native',
'pi-tui',
'pi-ai',
'pi-agent-core',
'pi-coding-agent',
]
const missing = []
for (const pkg of WORKSPACE_PACKAGES) {
const distIndex = join(packagesDir, pkg, 'dist', 'index.js')
if (!existsSync(distIndex)) {
missing.push(pkg)
}
}
if (missing.length === 0) process.exit(0)
process.stderr.write(` Building ${missing.length} workspace package(s) missing dist/: ${missing.join(', ')}\n`)
for (const pkg of missing) {
const pkgDir = join(packagesDir, pkg)
try {
execSync('npm run build', { cwd: pkgDir, stdio: 'pipe' })
process.stderr.write(`${pkg}\n`)
} catch (err) {
process.stderr.write(`${pkg} build failed: ${err.message}\n`)
// Non-fatal — the user can run `npm run build` manually
}
}

426
scripts/pr-risk-check.mjs Normal file
View file

@ -0,0 +1,426 @@
#!/usr/bin/env node
/**
* PR Risk Checker classifies changed files by system and outputs a risk report.
*
* Usage:
* node scripts/pr-risk-check.mjs # auto-detect changed files vs main
* node scripts/pr-risk-check.mjs --base <branch> # compare against a specific base
* node scripts/pr-risk-check.mjs --files a.ts,b.ts # explicit file list
* echo "src/cli.ts" | node scripts/pr-risk-check.mjs # pipe files via stdin
* node scripts/pr-risk-check.mjs --json # JSON output
* node scripts/pr-risk-check.mjs --github # GitHub Actions summary output
*/
import { readFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createInterface } from 'readline';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const MAP_PATH = resolve(REPO_ROOT, 'docs/FILE-SYSTEM-MAP.md');
// ---------------------------------------------------------------------------
// Risk tier definitions
// ---------------------------------------------------------------------------
const RISK_TIERS = {
critical: [
'State Machine', 'Agent Core', 'Auth/OAuth', 'Permissions',
'Auto Engine', 'MCP Server/Client', 'Native/Rust Tools',
],
high: [
'GSD Workflow', 'Tool System', 'AI Providers', 'Extension Registry',
'Session Management', 'Extensions', 'Modes', 'Event System',
'Node.js Bindings', 'Compaction',
],
medium: [
'Web UI', 'Web Mode', 'TUI Components', 'CLI', 'Commands', 'Worktree',
'API Routes', 'Doctor/Diagnostics', 'LSP', 'Model System',
'Subagent', 'Browser Tools', 'Bg Shell', 'Async Jobs', 'TTSR',
],
low: [
'Build System', 'Skills', 'Integration Tests', 'Config', 'Migration',
'Onboarding', 'Memory Extension', 'Studio App', 'VS Code Extension',
'Voice', 'CMux', 'Mac Tools', 'Universal Config', 'Remote Questions',
'Search the Web', 'Google Search', 'Context7', 'Slash Commands',
'File Search', 'Syntax Highlighting', 'Text Processing', 'Image Processing',
'AST', 'Loader/Bootstrap',
],
};
const TIER_ORDER = ['critical', 'high', 'medium', 'low'];
const TIER_EMOJI = {
critical: '🔴',
high: '🟠',
medium: '🟡',
low: '🟢',
};
// ---------------------------------------------------------------------------
// Parse FILE-SYSTEM-MAP.md
// ---------------------------------------------------------------------------
/**
* Returns a Map<normalizedPathPattern, string[]> of systems.
* Patterns ending in /* are treated as prefix matches.
*/
function parseMap(mapPath) {
if (!existsSync(mapPath)) {
throw new Error(`FILE-SYSTEM-MAP.md not found at ${mapPath}`);
}
const lines = readFileSync(mapPath, 'utf8').split('\n');
const entries = [];
for (const line of lines) {
// Only process table rows with at least 3 pipe-separated columns
if (!line.startsWith('|')) continue;
const cols = line.split('|').map(c => c.trim()).filter(Boolean);
if (cols.length < 2) continue;
// Skip header and separator rows
if (cols[0].startsWith('-') || cols[0].toLowerCase() === 'file' ||
cols[0].toLowerCase() === 'file path' || cols[0].toLowerCase() === 'skill directory' ||
cols[0].toLowerCase() === 'file / directory' || cols[0].toLowerCase() === 'system') continue;
const rawPath = cols[0];
const rawSystems = cols[1] || '';
// Skip bold section headers like **GSD Extension (Core Workflow Engine)**
if (rawPath.startsWith('**') || rawPath === '') continue;
// Clean up path — remove parenthetical notes like "(50+ files)"
const cleanPath = rawPath.replace(/\s*\(.*?\)/g, '').trim();
if (!cleanPath || cleanPath.startsWith('-')) continue;
// Parse systems — comma or pipe separated
const systems = rawSystems
.split(/[,|]/)
.map(s => s.trim())
.filter(Boolean)
.filter(s => !s.startsWith('-') && s !== 'System Label(s)');
if (systems.length === 0) continue;
entries.push({ pattern: cleanPath, systems });
}
return entries;
}
/**
* Normalize a file path to a repo-relative path for matching.
*/
function normalizePath(filePath) {
return filePath
.replace(/^\.\//, '')
.replace(/\\/g, '/');
}
/**
* Check if a changed file matches a map entry pattern.
* Supports:
* - Exact suffix match: src/cli.ts matches src/cli.ts
* - Glob prefix match: gsd/auto/* matches gsd/auto/anything.ts
* - Wildcard extension: *.tsx matches any .tsx
*/
function fileMatchesPattern(filePath, pattern) {
const file = normalizePath(filePath);
const pat = normalizePath(pattern);
// Glob prefix: ends with /* or /**
if (pat.endsWith('/*') || pat.endsWith('/**')) {
const prefix = pat.replace(/\/\*+$/, '/');
return file.includes(prefix);
}
// Wildcard extension: *.ext
if (pat.startsWith('*.')) {
return file.endsWith(pat.slice(1));
}
// Exact suffix match (map paths are relative, git paths may include root prefix)
return file === pat || file.endsWith('/' + pat) || pat.endsWith('/' + file);
}
/**
* Given a list of changed files and map entries, return matched systems.
*/
function classifyFiles(changedFiles, mapEntries) {
const systemsPerFile = new Map();
const unmatchedFiles = [];
for (const file of changedFiles) {
const matched = new Set();
for (const entry of mapEntries) {
if (fileMatchesPattern(file, entry.pattern)) {
entry.systems.forEach(s => matched.add(s));
}
}
if (matched.size > 0) {
systemsPerFile.set(file, [...matched]);
} else {
unmatchedFiles.push(file);
}
}
return { systemsPerFile, unmatchedFiles };
}
/**
* Get the risk tier for a system label.
*/
function tierForSystem(system) {
for (const tier of TIER_ORDER) {
if (RISK_TIERS[tier].some(s => system.includes(s) || s.includes(system))) {
return tier;
}
}
return 'low';
}
/**
* Aggregate overall risk from a set of system labels.
*/
function overallRisk(allSystems) {
let worst = 'low';
for (const system of allSystems) {
const tier = tierForSystem(system);
if (TIER_ORDER.indexOf(tier) < TIER_ORDER.indexOf(worst)) {
worst = tier;
}
}
return worst;
}
// ---------------------------------------------------------------------------
// Collect changed files
// ---------------------------------------------------------------------------
async function getChangedFilesFromStdin() {
return new Promise(resolve => {
const lines = [];
const rl = createInterface({ input: process.stdin, terminal: false });
rl.on('line', line => { if (line.trim()) lines.push(line.trim()); });
rl.on('close', () => resolve(lines));
});
}
function getChangedFilesFromGit(base = 'main') {
try {
const output = execSync(
`git diff --name-only ${base}...HEAD`,
{ cwd: REPO_ROOT, encoding: 'utf8' }
);
return output.trim().split('\n').filter(Boolean);
} catch {
// Fallback: compare staged + unstaged changes
try {
const output = execSync(
'git diff --name-only HEAD',
{ cwd: REPO_ROOT, encoding: 'utf8' }
);
return output.trim().split('\n').filter(Boolean);
} catch {
return [];
}
}
}
// ---------------------------------------------------------------------------
// Render output
// ---------------------------------------------------------------------------
function renderConsole(report) {
const { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk } = report;
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' GSD2 PR Risk Report');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log(`Overall Risk: ${TIER_EMOJI[risk]} ${risk.toUpperCase()}`);
console.log(`Files changed: ${changedFiles.length} | Systems affected: ${systemRisks.length}\n`);
if (systemRisks.length > 0) {
console.log('Affected Systems:');
for (const { system, tier } of systemRisks) {
console.log(` ${TIER_EMOJI[tier]} ${system}`);
}
console.log('');
}
if (systemsPerFile.size > 0) {
console.log('File Breakdown:');
for (const [file, systems] of systemsPerFile) {
const tier = overallRisk(systems);
console.log(` ${TIER_EMOJI[tier]} ${file}`);
console.log(`${systems.join(', ')}`);
}
console.log('');
}
if (unmatchedFiles.length > 0) {
console.log(`Unclassified files (${unmatchedFiles.length}):`);
unmatchedFiles.forEach(f => console.log(`${f}`));
console.log('');
}
// Reviewer checklist
if (risk === 'critical') {
console.log('⚠️ Reviewer checklist for CRITICAL changes:');
console.log(' • Test state persistence across session restart');
console.log(' • Verify auth token lifecycle (create, refresh, revoke)');
console.log(' • Check for race conditions in agent loop');
console.log(' • Ensure no breaking changes to RPC protocol');
} else if (risk === 'high') {
console.log('⚠️ Reviewer checklist for HIGH-risk changes:');
console.log(' • Run full integration test suite');
console.log(' • Verify tool call/response contracts unchanged');
console.log(' • Check extension event dispatch still works');
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
function renderGitHubSummary(report) {
const { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk } = report;
const lines = [];
lines.push(`## ${TIER_EMOJI[risk]} PR Risk Report — ${risk.toUpperCase()}`);
lines.push('');
lines.push(`| | |`);
lines.push(`|---|---|`);
lines.push(`| **Files changed** | ${changedFiles.length} |`);
lines.push(`| **Systems affected** | ${systemRisks.length} |`);
lines.push(`| **Overall risk** | ${TIER_EMOJI[risk]} ${risk.toUpperCase()} |`);
lines.push('');
if (systemRisks.length > 0) {
lines.push('### Affected Systems');
lines.push('');
lines.push('| Risk | System |');
lines.push('|------|--------|');
for (const { system, tier } of systemRisks) {
lines.push(`| ${TIER_EMOJI[tier]} ${tier} | ${system} |`);
}
lines.push('');
}
if (systemsPerFile.size > 0) {
lines.push('<details>');
lines.push('<summary>File Breakdown</summary>');
lines.push('');
lines.push('| Risk | File | Systems |');
lines.push('|------|------|---------|');
for (const [file, systems] of systemsPerFile) {
const tier = overallRisk(systems);
lines.push(`| ${TIER_EMOJI[tier]} | \`${file}\` | ${systems.join(', ')} |`);
}
if (unmatchedFiles.length > 0) {
for (const file of unmatchedFiles) {
lines.push(`| ⚪ | \`${file}\` | *(unclassified)* |`);
}
}
lines.push('');
lines.push('</details>');
lines.push('');
}
if (risk === 'critical') {
lines.push('> ⚠️ **Critical risk** — please verify: state persistence, auth token lifecycle, agent loop race conditions, RPC protocol compatibility.');
} else if (risk === 'high') {
lines.push('> ⚠️ **High risk** — please run full integration tests and verify tool/extension contracts.');
}
return lines.join('\n');
}
function buildReport({ changedFiles, systemsPerFile, unmatchedFiles }) {
// Aggregate all systems
const allSystems = new Set();
for (const systems of systemsPerFile.values()) {
systems.forEach(s => allSystems.add(s));
}
// Build system → tier list, sorted by risk
const systemRisks = [...allSystems]
.map(system => ({ system, tier: tierForSystem(system) }))
.sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier));
const risk = overallRisk(allSystems);
return { changedFiles, systemsPerFile, unmatchedFiles, systemRisks, risk };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const args = process.argv.slice(2);
const isJson = args.includes('--json');
const isGitHub = args.includes('--github');
// Collect changed files
let changedFiles;
const filesIdx = args.indexOf('--files');
if (filesIdx !== -1 && args[filesIdx + 1]) {
changedFiles = args[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean);
} else if (!process.stdin.isTTY) {
changedFiles = await getChangedFilesFromStdin();
} else {
const baseIdx = args.indexOf('--base');
const base = baseIdx !== -1 && args[baseIdx + 1] ? args[baseIdx + 1] : 'main';
changedFiles = getChangedFilesFromGit(base);
}
if (changedFiles.length === 0) {
console.log('No changed files detected.');
process.exit(0);
}
// Load and parse map
const mapEntries = parseMap(MAP_PATH);
// Classify
const { systemsPerFile, unmatchedFiles } = classifyFiles(changedFiles, mapEntries);
const report = buildReport({ changedFiles, systemsPerFile, unmatchedFiles });
// Output
if (isJson) {
console.log(JSON.stringify({
risk: report.risk,
filesChanged: report.changedFiles.length,
systemsAffected: report.systemRisks,
fileBreakdown: Object.fromEntries(report.systemsPerFile),
unclassified: report.unmatchedFiles,
}, null, 2));
} else if (isGitHub) {
const summary = renderGitHubSummary(report);
// Write to GitHub step summary if available
if (process.env.GITHUB_STEP_SUMMARY) {
const { appendFileSync } = await import('fs');
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n');
}
// Also output the summary markdown for use in PR comments
console.log(summary);
} else {
renderConsole(report);
}
// Exit with non-zero for critical so CI can gate on it if desired
if (report.risk === 'critical') {
process.exitCode = 2;
} else if (report.risk === 'high') {
process.exitCode = 1;
}
}
main().catch(err => {
console.error('pr-risk-check error:', err.message);
process.exit(1);
});

View file

@ -0,0 +1,339 @@
# recover-gsd-1668.ps1 — Recovery script for issue #1668 (Windows)
#
# GSD v2.39.x deleted the milestone branch and worktree directory when a
# merge failed due to the repo using `master` as its default branch (not
# `main`). The commits were never merged — they are orphaned in the git
# object store and can be recovered via git reflog or git fsck.
#
# This script:
# 1. Searches git reflog for the deleted milestone branch (fastest path)
# 2. Falls back to git fsck --unreachable to find orphaned commits
# 3. Ranks candidates by recency and GSD commit message patterns
# 4. Creates a recovery branch at the identified commit
# 5. Reports what was found and how to complete the merge manually
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts\recover-gsd-1668.ps1 [-MilestoneId <ID>] [-DryRun] [-Auto]
#
# Options:
# -MilestoneId <ID> GSD milestone ID (e.g. M001-g2nalq).
# -DryRun Show what would be done without making any changes.
# -Auto Pick best candidate automatically (no prompts).
#
# Requirements: git >= 2.23, PowerShell >= 5.1, Git for Windows
#
# Affected versions: GSD 2.39.x
# Fixed in: GSD 2.40.1 (PR #1669)
[CmdletBinding()]
param(
[string]$MilestoneId = "",
[switch]$DryRun,
[switch]$Auto
)
$ErrorActionPreference = 'Stop'
# ── Helpers ───────────────────────────────────────────────────────────────────
function Info { param($msg) Write-Host "[info] $msg" -ForegroundColor Cyan }
function Ok { param($msg) Write-Host "[ok] $msg" -ForegroundColor Green }
function Warn { param($msg) Write-Host "[warn] $msg" -ForegroundColor Yellow }
function Err { param($msg) Write-Host "[error] $msg" -ForegroundColor Red }
function Section { param($msg) Write-Host "`n$msg" -ForegroundColor White }
function Dim { param($msg) Write-Host " $msg" -ForegroundColor DarkGray }
function Run {
param($cmd)
if ($DryRun) {
Write-Host " (dry-run) $cmd" -ForegroundColor Yellow
} else {
Invoke-Expression $cmd
}
}
function Git {
param([string[]]$args)
$output = & git @args 2>&1
if ($LASTEXITCODE -ne 0) { return "" }
return $output -join "`n"
}
function Die {
param($msg)
Err $msg
exit 1
}
# ── Preflight ─────────────────────────────────────────────────────────────────
Section "── Preflight ───────────────────────────────────────────────────────────"
$gitDir = & git rev-parse --git-dir 2>&1
if ($LASTEXITCODE -ne 0) {
Die "Not inside a git repository. Run this from your project root."
}
$repoRoot = (& git rev-parse --show-toplevel).Trim()
Set-Location $repoRoot
Info "Repo root: $repoRoot"
if ($DryRun) { Warn "DRY-RUN mode — no changes will be made." }
# ── Step 1: Check live milestone branches ────────────────────────────────────
Section "── Step 1: Verify milestone branch is missing ───────────────────────────"
$branchPattern = if ($MilestoneId) { "milestone/$MilestoneId" } else { "milestone/" }
$liveBranches = & git branch 2>/dev/null | Where-Object { $_ -match [regex]::Escape($branchPattern) } | ForEach-Object { $_.Trim().TrimStart('* ') }
if ($liveBranches) {
Ok "Found live milestone branch(es):"
$liveBranches | ForEach-Object { Write-Host " $_" }
Warn "The branch still exists — are you sure it was lost?"
Write-Host " git checkout $($liveBranches[0])"
if (-not $MilestoneId) { exit 0 }
}
if ($MilestoneId -and -not $liveBranches) {
Info "Confirmed: milestone/$MilestoneId branch is gone."
} elseif (-not $MilestoneId) {
Info "No live milestone/ branches found — scanning for orphaned commits."
}
# ── Step 2: Search git reflog ─────────────────────────────────────────────────
Section "── Step 2: Search git reflog for deleted branch ────────────────────────"
$reflogFoundSha = ""
$reflogFoundBranch = ""
if ($MilestoneId) {
$reflogPath = Join-Path $repoRoot ".git\logs\refs\heads\milestone\$MilestoneId"
if (Test-Path $reflogPath) {
$lines = Get-Content $reflogPath
if ($lines) {
$lastLine = $lines[-1]
$reflogFoundSha = ($lastLine -split '\s+')[1]
$reflogFoundBranch = "milestone/$MilestoneId"
Ok "Reflog entry found for milestone/$MilestoneId — commit: $($reflogFoundSha.Substring(0,12))"
}
} else {
Info "No reflog file at .git\logs\refs\heads\milestone\$MilestoneId"
}
}
if (-not $reflogFoundSha) {
Info "Scanning git reflog for milestone/ commits..."
$reflogAll = & git reflog --all --format="%H %gs" 2>/dev/null | Where-Object { $_ -match "milestone/" } | Select-Object -First 20
if ($reflogAll) {
Info "Found milestone-related reflog entries:"
$reflogAll | ForEach-Object { Dim $_ }
$match = if ($MilestoneId) {
$reflogAll | Where-Object { $_ -match "milestone/$([regex]::Escape($MilestoneId))" } | Select-Object -First 1
} else {
$reflogAll | Select-Object -First 1
}
if ($match) {
$reflogFoundSha = ($match -split '\s+')[0]
if ($match -match 'milestone/(\S+)') { $reflogFoundBranch = "milestone/$($Matches[1])" }
else { $reflogFoundBranch = "milestone/unknown" }
}
} else {
Info "No milestone/ entries in reflog."
}
}
# ── Step 3: Fall back to git fsck ─────────────────────────────────────────────
Section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────"
$sortedCandidates = @()
if (-not $reflogFoundSha) {
Info "Running git fsck --unreachable (this may take a moment)..."
$fsckOutput = & git fsck --unreachable --no-reflogs 2>/dev/null | Where-Object { $_ -match '^unreachable commit' }
if (-not $fsckOutput) {
$fsckOutput = & git fsck --unreachable 2>/dev/null | Where-Object { $_ -match '^unreachable commit' }
}
$unreachableCommits = $fsckOutput | ForEach-Object { ($_ -split '\s+')[2] } | Where-Object { $_ }
$total = @($unreachableCommits).Count
Info "Found $total unreachable commit object(s)."
if ($total -eq 0) {
Err "No unreachable commits found."
Write-Host ""
Write-Host "This means one of:"
Write-Host " 1. git gc has already pruned the objects (default: 14 days)"
Write-Host " 2. The commits were never written to the object store"
Write-Host " 3. The wrong repository is being scanned"
exit 1
}
$cutoff = (Get-Date).AddDays(-30).ToUnixTimeSeconds()
$candidates = @()
foreach ($sha in $unreachableCommits) {
if (-not $sha) { continue }
$commitDate = [long](& git show -s --format="%ct" $sha 2>/dev/null)
if (-not $commitDate -or $commitDate -lt $cutoff) { continue }
$commitMsg = (& git show -s --format="%s" $sha 2>/dev/null) -join ""
$commitBody = (& git show -s --format="%b" $sha 2>/dev/null) -join " "
$commitDateHr = (& git show -s --format="%ci" $sha 2>/dev/null) -join ""
$score = 0
if ($MilestoneId -and ($commitMsg + $commitBody) -match [regex]::Escape($MilestoneId)) { $score += 100 }
if ($commitMsg -match '^feat\([A-Z][0-9]+') { $score += 50 }
if (($commitMsg + $commitBody) -match 'milestone/|complete-milestone|GSD|slice') { $score += 20 }
$weekAgo = (Get-Date).AddDays(-7).ToUnixTimeSeconds()
if ($commitDate -gt $weekAgo) { $score += 10 }
$fileCount = (& git show --stat --format="" $sha 2>/dev/null | Select-Object -Last 1) -replace '.*?(\d+) file.*','$1'
$candidates += [PSCustomObject]@{
SHA = $sha
Score = $score
Message = $commitMsg
Date = $commitDateHr
FileCount = $fileCount
}
}
if ($candidates.Count -eq 0) {
Err "No recent unreachable commits found within the last 30 days."
Write-Host "Objects may have been pruned by git gc."
exit 1
}
$sortedCandidates = $candidates | Sort-Object -Property Score -Descending | Select-Object -First 10
Info "Top candidates (scored by recency and GSD message patterns):"
Write-Host ""
$num = 1
foreach ($c in $sortedCandidates) {
Write-Host " $num) $($c.SHA.Substring(0,12)) $($c.Message)" -ForegroundColor Green
Dim "$($c.Date)$($c.FileCount) file(s)"
$num++
}
Write-Host ""
}
# ── Step 4: Select recovery commit ───────────────────────────────────────────
Section "── Step 4: Select recovery commit ──────────────────────────────────────"
$recoverySha = ""
$recoverySource = ""
if ($reflogFoundSha) {
$recoverySha = $reflogFoundSha
$recoverySource = "reflog ($reflogFoundBranch)"
Info "Using reflog candidate: $($recoverySha.Substring(0,12))"
Dim (& git show -s --format="%s %ci" $recoverySha 2>/dev/null)
} elseif ($sortedCandidates.Count -eq 1 -or $Auto) {
$recoverySha = $sortedCandidates[0].SHA
$recoverySource = "fsck (auto-selected)"
Info "Auto-selecting best candidate: $($recoverySha.Substring(0,12))"
} else {
$selection = Read-Host "Select a candidate to recover [1-$($sortedCandidates.Count), or q to quit]"
if ($selection -eq 'q') { Info "Aborted."; exit 0 }
$selIdx = [int]$selection - 1
if ($selIdx -lt 0 -or $selIdx -ge $sortedCandidates.Count) { Die "Invalid selection: $selection" }
$recoverySha = $sortedCandidates[$selIdx].SHA
$recoverySource = "fsck (user-selected #$selection)"
}
if (-not $recoverySha) { Die "Could not determine a recovery commit." }
Ok "Recovery commit: $($recoverySha.Substring(0,16)) (source: $recoverySource)"
Write-Host ""
Info "Commit details:"
& git show -s --format=" Message: %s`n Author: %an <%ae>`n Date: %ci`n Full SHA: %H" $recoverySha
Write-Host ""
Info "Files at this commit (first 30):"
& git show --stat --format="" $recoverySha 2>/dev/null | Select-Object -First 30
Write-Host ""
# ── Step 5: Create recovery branch ───────────────────────────────────────────
Section "── Step 5: Create recovery branch ──────────────────────────────────────"
$recoveryBranch = if ($MilestoneId) {
"recovery/1668/$MilestoneId"
} elseif ($reflogFoundBranch) {
"recovery/1668/$($reflogFoundBranch -replace '/','-')"
} else {
"recovery/1668/commit-$($recoverySha.Substring(0,8))"
}
$branchExists = & git show-ref --verify --quiet "refs/heads/$recoveryBranch" 2>/dev/null; $exists = $LASTEXITCODE -eq 0
if ($exists) {
Warn "Branch $recoveryBranch already exists."
if (-not $Auto) {
$answer = Read-Host "Overwrite it? [y/N]"
if ($answer -notin @('y','Y')) { Info "Aborted."; exit 0 }
}
Run "git branch -D `"$recoveryBranch`""
}
Run "git branch `"$recoveryBranch`" `"$recoverySha`""
if (-not $DryRun) {
Ok "Recovery branch created: $recoveryBranch"
} else {
Ok "(dry-run) Would create branch: $recoveryBranch -> $($recoverySha.Substring(0,12))"
}
# ── Step 6: Verify ────────────────────────────────────────────────────────────
if (-not $DryRun) {
Section "── Step 6: Verify recovery branch ──────────────────────────────────────"
$fileList = & git ls-tree -r --name-only $recoveryBranch 2>/dev/null | Where-Object { $_ -notmatch '^\.gsd/' }
$fileCount = @($fileList).Count
Info "Files recoverable (excluding .gsd/ state files): $fileCount"
$fileList | Select-Object -First 30 | ForEach-Object { Write-Host " $_" }
if ($fileCount -gt 30) { Dim " ... and $($fileCount - 30) more" }
}
# ── Summary ───────────────────────────────────────────────────────────────────
Section "── Recovery Summary ─────────────────────────────────────────────────────"
if ($DryRun) {
Write-Host "Dry-run complete. Re-run without -DryRun to apply." -ForegroundColor Yellow
exit 0
}
$defaultBranch = (& git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null) -replace 'refs/remotes/origin/',''
if (-not $defaultBranch) { $defaultBranch = (& git branch --show-current) }
Write-Host "Recovery branch ready: " -NoNewline
Write-Host $recoveryBranch -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:"
Write-Host ""
Write-Host " 1. Inspect the recovered files:"
Write-Host " git checkout $recoveryBranch"
Write-Host " dir"
Write-Host ""
Write-Host " 2. Verify your code is intact:"
Write-Host " git log --oneline $recoveryBranch | head -20"
Write-Host ""
Write-Host " 3. Merge to your default branch ($defaultBranch):"
Write-Host " git checkout $defaultBranch"
Write-Host " git merge --squash $recoveryBranch"
Write-Host " git commit -m `"feat: recover milestone from #1668`""
Write-Host ""
Write-Host " 4. Clean up after verifying:"
Write-Host " git branch -D $recoveryBranch"
Write-Host ""
Write-Host "Note: update GSD to v2.40.1+ to prevent this from recurring." -ForegroundColor DarkGray
Write-Host " PR: https://github.com/gsd-build/gsd-2/pull/1669" -ForegroundColor DarkGray
Write-Host ""

446
scripts/recover-gsd-1668.sh Executable file
View file

@ -0,0 +1,446 @@
#!/usr/bin/env bash
# recover-gsd-1668.sh — Recovery script for issue #1668 (Linux / macOS)
#
# GSD v2.39.x deleted the milestone branch and worktree directory when a
# merge failed due to the repo using `master` as its default branch (not
# `main`). The commits were never merged — they are orphaned in the git
# object store and can be recovered via git reflog or git fsck.
#
# This script:
# 1. Searches git reflog for the deleted milestone branch (fastest path)
# 2. Falls back to git fsck --unreachable to find orphaned commits
# 3. Ranks candidates by recency and GSD commit message patterns
# 4. Creates a recovery branch at the identified commit
# 5. Reports what was found and how to complete the merge manually
#
# Usage:
# bash scripts/recover-gsd-1668.sh [--milestone <ID>] [--dry-run] [--auto]
#
# Options:
# --milestone <ID> GSD milestone ID (e.g. M001-g2nalq).
# When omitted the script scans all recent orphans.
# --dry-run Show what would be done without making any changes.
# --auto Pick the best candidate automatically (no prompts).
#
# Requirements: git >= 2.23, bash >= 4.x
#
# Affected versions: GSD 2.39.x
# Fixed in: GSD 2.40.1 (PR #1669)
set -euo pipefail
# ─── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
# ─── Args ─────────────────────────────────────────────────────────────────────
DRY_RUN=false
AUTO=false
MILESTONE_ID=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--auto) AUTO=true; shift ;;
--milestone)
[[ $# -lt 2 ]] && { echo "Error: --milestone requires an argument" >&2; exit 1; }
MILESTONE_ID="$2"; shift 2 ;;
--milestone=*)
MILESTONE_ID="${1#--milestone=}"; shift ;;
-h|--help)
sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: $0 [--milestone <ID>] [--dry-run] [--auto]" >&2
exit 1 ;;
esac
done
# ─── Helpers ──────────────────────────────────────────────────────────────────
info() { echo -e "${CYAN}[info]${RESET} $*"; }
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*"; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
section() { echo -e "\n${BOLD}$*${RESET}"; }
dim() { echo -e "${DIM}$*${RESET}"; }
die() {
error "$*"
exit 1
}
run() {
if $DRY_RUN; then
echo -e " ${YELLOW}(dry-run)${RESET} $*"
else
eval "$*"
fi
}
# ─── Preflight ────────────────────────────────────────────────────────────────
section "── Preflight ───────────────────────────────────────────────────────────"
if ! git rev-parse --git-dir > /dev/null 2>&1; then
die "Not inside a git repository. Run this from your project root."
fi
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
info "Repo root: $REPO_ROOT"
$DRY_RUN && warn "DRY-RUN mode — no changes will be made."
# ─── Step 1: Confirm the milestone branch is gone ─────────────────────────────
section "── Step 1: Verify milestone branch is missing ───────────────────────────"
BRANCH_PATTERN="milestone/"
if [[ -n "$MILESTONE_ID" ]]; then
BRANCH_PATTERN="milestone/${MILESTONE_ID}"
fi
LIVE_BRANCHES="$(git branch | grep "$BRANCH_PATTERN" 2>/dev/null | tr -d '* ' || true)"
if [[ -n "$LIVE_BRANCHES" ]]; then
ok "Found live milestone branch(es):"
echo "$LIVE_BRANCHES" | while IFS= read -r b; do echo " $b"; done
echo ""
warn "The branch still exists — are you sure it was lost?"
echo " If you want to check out existing work: git checkout ${LIVE_BRANCHES%%$'\n'*}"
echo " To merge it manually: git checkout master && git merge --squash ${LIVE_BRANCHES%%$'\n'*}"
echo ""
echo "Re-run with --milestone <ID> to force scanning for a specific orphaned commit."
if [[ -z "$MILESTONE_ID" ]]; then
exit 0
fi
fi
if [[ -n "$MILESTONE_ID" && -n "$LIVE_BRANCHES" ]]; then
warn "Milestone branch milestone/${MILESTONE_ID} is still live — continuing scan anyway."
elif [[ -n "$MILESTONE_ID" ]]; then
info "Confirmed: milestone/${MILESTONE_ID} branch is gone."
else
info "No live milestone/ branches found — scanning for orphaned commits."
fi
# ─── Step 2: Search git reflog (fastest, most reliable) ───────────────────────
section "── Step 2: Search git reflog for deleted branch ────────────────────────"
# git reflog stores branch moves and deletions in .git/logs/refs/heads/
# It is retained for 90 days by default (gc.reflogExpire).
REFLOG_FOUND_SHA=""
REFLOG_FOUND_BRANCH=""
if [[ -n "$MILESTONE_ID" ]]; then
REFLOG_PATH="${REPO_ROOT}/.git/logs/refs/heads/milestone/${MILESTONE_ID}"
if [[ -f "$REFLOG_PATH" ]]; then
# Last line of the reflog for this branch is the most recent tip
REFLOG_FOUND_SHA="$(tail -1 "$REFLOG_PATH" | awk '{print $2}')"
REFLOG_FOUND_BRANCH="milestone/${MILESTONE_ID}"
ok "Reflog entry found for milestone/${MILESTONE_ID} — commit: ${REFLOG_FOUND_SHA:0:12}"
else
info "No reflog file at .git/logs/refs/heads/milestone/${MILESTONE_ID}"
fi
fi
# Also try git reflog (in-memory index, works without the raw file)
if [[ -z "$REFLOG_FOUND_SHA" ]]; then
info "Scanning git reflog for milestone/ commits..."
REFLOG_MILESTONES="$(git reflog --all --format="%H %gs" 2>/dev/null \
| grep -E "(checkout|commit|merge).*milestone/" \
| head -20 || true)"
if [[ -n "$REFLOG_MILESTONES" ]]; then
info "Found milestone-related reflog entries:"
echo "$REFLOG_MILESTONES" | while IFS= read -r line; do
dim " $line"
done
# Extract the most recent SHA from the most relevant entry
if [[ -n "$MILESTONE_ID" ]]; then
MATCH="$(echo "$REFLOG_MILESTONES" | grep "milestone/${MILESTONE_ID}" | head -1 || true)"
else
MATCH="$(echo "$REFLOG_MILESTONES" | head -1 || true)"
fi
if [[ -n "$MATCH" ]]; then
REFLOG_FOUND_SHA="$(echo "$MATCH" | awk '{print $1}')"
REFLOG_FOUND_BRANCH="$(echo "$MATCH" | grep -oE 'milestone/[^ ]+' | head -1 || echo "milestone/unknown")"
fi
else
info "No milestone/ entries in reflog."
fi
fi
# ─── Step 3: Fall back to git fsck if reflog didn't find it ───────────────────
section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────"
FSCK_CANDIDATES=()
FSCK_CANDIDATE_MSGS=()
FSCK_CANDIDATE_DATES=()
FSCK_CANDIDATE_FILES=()
if [[ -z "$REFLOG_FOUND_SHA" ]]; then
info "Running git fsck --unreachable (this may take a moment)..."
# Collect all unreachable commit hashes
UNREACHABLE_COMMITS="$(git fsck --unreachable --no-reflogs 2>/dev/null \
| grep '^unreachable commit' \
| awk '{print $3}' || true)"
if [[ -z "$UNREACHABLE_COMMITS" ]]; then
# Try without --no-reflogs as a fallback (less conservative)
UNREACHABLE_COMMITS="$(git fsck --unreachable 2>/dev/null \
| grep '^unreachable commit' \
| awk '{print $3}' || true)"
fi
TOTAL="$(echo "$UNREACHABLE_COMMITS" | grep -c . || true)"
info "Found ${TOTAL} unreachable commit object(s)."
if [[ -z "$UNREACHABLE_COMMITS" || "$TOTAL" -eq 0 ]]; then
error "No unreachable commits found."
echo ""
echo "This means one of:"
echo " 1. git gc has already been run and the objects were pruned"
echo " (objects are pruned after 14 days by default)"
echo " 2. The commits were never written to the object store"
echo " 3. The wrong repository is being scanned"
echo ""
echo "If git gc ran, the objects may be unrecoverable without a backup."
echo "Try: git reflog --all | grep milestone"
exit 1
fi
# Score each unreachable commit — rank by recency and GSD message patterns.
# GSD milestone commits look like: "feat(M001-g2nalq): <title>"
# Slice merges look like: "feat(M001-g2nalq/S01): <slice>"
#
# Performance: use a single `git log --no-walk=unsorted --stdin` call to
# read all commit metadata in one pass instead of one `git show` per commit.
CUTOFF="$(date -d '30 days ago' '+%s' 2>/dev/null || date -v-30d '+%s' 2>/dev/null || echo 0)"
WEEK_AGO="$(date -d '7 days ago' '+%s' 2>/dev/null || date -v-7d '+%s' 2>/dev/null || echo 0)"
# Batch-read all commits: output format per commit is:
# HASH<TAB>UNIX_TIMESTAMP<TAB>ISO_DATE<TAB>SUBJECT
# separated by NUL so multi-line subjects don't break parsing.
BATCH_LOG="$(echo "$UNREACHABLE_COMMITS" \
| git log --no-walk=unsorted --stdin --format=$'%H\t%ct\t%ci\t%s' 2>/dev/null || true)"
while IFS=$'\t' read -r sha commit_ts commit_date_hr commit_msg; do
[[ -z "$sha" ]] && continue
[[ -z "$commit_ts" || "$commit_ts" -lt "$CUTOFF" ]] && continue
# Score: milestone pattern in subject is highest signal
SCORE=0
if [[ -n "$MILESTONE_ID" ]] && echo "$commit_msg" | grep -qiE "(milestone[/ ])?${MILESTONE_ID}"; then
SCORE=$((SCORE + 100))
fi
if echo "$commit_msg" | grep -qE '^feat\([A-Z][0-9]+'; then
SCORE=$((SCORE + 50))
fi
if echo "$commit_msg" | grep -qiE 'milestone/|complete-milestone|GSD|slice'; then
SCORE=$((SCORE + 20))
fi
if [[ "$commit_ts" -gt "$WEEK_AGO" ]]; then
SCORE=$((SCORE + 10))
fi
FSCK_CANDIDATES+=("$sha|$SCORE")
FSCK_CANDIDATE_MSGS+=("$commit_msg")
FSCK_CANDIDATE_DATES+=("$commit_date_hr")
FSCK_CANDIDATE_FILES+=("?")
done <<< "$BATCH_LOG"
if [[ ${#FSCK_CANDIDATES[@]} -eq 0 ]]; then
error "No recent unreachable commits found within the last 30 days."
echo ""
echo "Objects may have been pruned by git gc, or the issue occurred more than 30 days ago."
echo "Try: git fsck --unreachable --no-reflogs 2>/dev/null | grep commit"
exit 1
fi
# Sort by score descending, keep top 10
IFS=$'\n' SORTED_CANDIDATES=($(
for i in "${!FSCK_CANDIDATES[@]}"; do
echo "${FSCK_CANDIDATES[$i]}|$i"
done | sort -t'|' -k2 -rn | head -10
))
unset IFS
info "Top candidates (scored by recency and GSD message patterns):"
echo ""
NUM=1
SORTED_IDXS=()
for entry in "${SORTED_CANDIDATES[@]}"; do
SHA="${entry%%|*}"
IDX="${entry##*|}"
SORTED_IDXS+=("$IDX")
MSG="${FSCK_CANDIDATE_MSGS[$IDX]}"
DATE="${FSCK_CANDIDATE_DATES[$IDX]}"
FILES="${FSCK_CANDIDATE_FILES[$IDX]}"
echo -e " ${BOLD}${NUM})${RESET} ${sha:0:12} ${GREEN}${MSG}${RESET}"
echo -e " ${DIM}${DATE}${FILES}${RESET}"
NUM=$((NUM + 1))
done
echo ""
fi
# ─── Step 4: Select the recovery commit ───────────────────────────────────────
section "── Step 4: Select recovery commit ──────────────────────────────────────"
RECOVERY_SHA=""
RECOVERY_SOURCE=""
if [[ -n "$REFLOG_FOUND_SHA" ]]; then
RECOVERY_SHA="$REFLOG_FOUND_SHA"
RECOVERY_SOURCE="reflog (${REFLOG_FOUND_BRANCH})"
info "Using reflog candidate: ${RECOVERY_SHA:0:12}"
MSG="$(git show -s --format="%s %ci" "$RECOVERY_SHA" 2>/dev/null || echo "unknown")"
dim " $MSG"
elif [[ ${#SORTED_IDXS[@]} -eq 1 ]] || $AUTO; then
# Auto-select first (highest scored) candidate
FIRST_ENTRY="${SORTED_CANDIDATES[0]}"
FIRST_SHA="${FIRST_ENTRY%%|*}"
FIRST_IDX="${FIRST_ENTRY##*|}"
RECOVERY_SHA="$FIRST_SHA"
RECOVERY_SOURCE="fsck (auto-selected)"
info "Auto-selecting best candidate: ${RECOVERY_SHA:0:12}"
else
# Prompt user to select
echo -n "Select a candidate to recover [1-${#SORTED_CANDIDATES[@]}, or q to quit]: "
read -r SELECTION
if [[ "$SELECTION" == "q" ]]; then
info "Aborted."
exit 0
fi
if ! [[ "$SELECTION" =~ ^[0-9]+$ ]] || \
[[ "$SELECTION" -lt 1 ]] || \
[[ "$SELECTION" -gt ${#SORTED_CANDIDATES[@]} ]]; then
die "Invalid selection: $SELECTION"
fi
SEL_IDX=$((SELECTION - 1))
SEL_ENTRY="${SORTED_CANDIDATES[$SEL_IDX]}"
RECOVERY_SHA="${SEL_ENTRY%%|*}"
RECOVERY_SOURCE="fsck (user-selected #${SELECTION})"
fi
if [[ -z "$RECOVERY_SHA" ]]; then
die "Could not determine a recovery commit. See output above."
fi
ok "Recovery commit: ${RECOVERY_SHA:0:16} (source: ${RECOVERY_SOURCE})"
# Show what's in this commit
echo ""
info "Commit details:"
git show -s --format=" Message: %s%n Author: %an <%ae>%n Date: %ci%n Full SHA: %H" "$RECOVERY_SHA"
echo ""
info "Files at this commit (first 30):"
git show --stat --format="" "$RECOVERY_SHA" 2>/dev/null | head -30
echo ""
# ─── Step 5: Create recovery branch ───────────────────────────────────────────
section "── Step 5: Create recovery branch ──────────────────────────────────────"
# Determine recovery branch name
if [[ -n "$MILESTONE_ID" ]]; then
RECOVERY_BRANCH="recovery/1668/${MILESTONE_ID}"
elif [[ -n "$REFLOG_FOUND_BRANCH" ]]; then
CLEAN_NAME="${REFLOG_FOUND_BRANCH//\//-}"
RECOVERY_BRANCH="recovery/1668/${CLEAN_NAME}"
else
SHORT_SHA="${RECOVERY_SHA:0:8}"
RECOVERY_BRANCH="recovery/1668/commit-${SHORT_SHA}"
fi
# Check if it already exists
if git show-ref --verify --quiet "refs/heads/${RECOVERY_BRANCH}" 2>/dev/null; then
warn "Branch ${RECOVERY_BRANCH} already exists."
if ! $AUTO; then
echo -n "Overwrite it? [y/N]: "
read -r ANSWER
if [[ "$ANSWER" != "y" && "$ANSWER" != "Y" ]]; then
info "Aborted. Existing branch preserved."
exit 0
fi
fi
run "git branch -D \"${RECOVERY_BRANCH}\""
fi
run "git branch \"${RECOVERY_BRANCH}\" \"${RECOVERY_SHA}\""
if ! $DRY_RUN; then
ok "Recovery branch created: ${RECOVERY_BRANCH}"
else
ok "(dry-run) Would create branch: ${RECOVERY_BRANCH}${RECOVERY_SHA:0:12}"
fi
# ─── Step 6: Verify the recovery branch ───────────────────────────────────────
if ! $DRY_RUN; then
section "── Step 6: Verify recovery branch ──────────────────────────────────────"
FILE_LIST="$(git ls-tree -r --name-only "${RECOVERY_BRANCH}" 2>/dev/null | grep -v '^\.gsd/' || true)"
FILE_COUNT="$(echo "$FILE_LIST" | grep -c . || true)"
info "Files recoverable (excluding .gsd/ state files): ${FILE_COUNT}"
echo "$FILE_LIST" | head -30 | while IFS= read -r f; do echo " $f"; done
if [[ "$FILE_COUNT" -gt 30 ]]; then
dim " ... and $((FILE_COUNT - 30)) more"
fi
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
section "── Recovery Summary ─────────────────────────────────────────────────────"
if $DRY_RUN; then
echo -e "${YELLOW}Dry-run complete. Re-run without --dry-run to apply.${RESET}"
exit 0
fi
DEFAULT_BRANCH="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' \
|| git for-each-ref --format='%(refname:short)' 'refs/heads/main' 'refs/heads/master' 2>/dev/null | head -1 \
|| git branch --show-current)"
echo -e "${GREEN}Recovery branch ready: ${BOLD}${RECOVERY_BRANCH}${RESET}"
echo ""
echo "Next steps:"
echo ""
echo -e " ${BOLD}1. Inspect the recovered files:${RESET}"
echo " git checkout ${RECOVERY_BRANCH}"
echo " ls -la"
echo ""
echo -e " ${BOLD}2. Verify your code is intact:${RESET}"
echo " git log --oneline ${RECOVERY_BRANCH} | head -20"
echo " git show --stat ${RECOVERY_BRANCH}"
echo ""
echo -e " ${BOLD}3. Merge to your default branch (${DEFAULT_BRANCH}):${RESET}"
echo " git checkout ${DEFAULT_BRANCH}"
echo " git merge --squash ${RECOVERY_BRANCH}"
echo " git commit -m \"feat: recover milestone from #1668\""
echo ""
echo -e " ${BOLD}4. Clean up after verifying:${RESET}"
echo " git branch -D ${RECOVERY_BRANCH}"
echo ""
echo -e "${DIM}Note: update GSD to v2.40.1+ to prevent this from recurring.${RESET}"
echo " PR: https://github.com/gsd-build/gsd-2/pull/1669"
echo ""

View file

@ -0,0 +1,73 @@
#!/usr/bin/env node
const { cpSync, existsSync, mkdirSync, readdirSync, rmSync } = require('node:fs')
const { join, resolve } = require('node:path')
const root = resolve(__dirname, '..')
const webRoot = join(root, 'web')
const standaloneRoot = join(webRoot, '.next', 'standalone')
const standaloneAppRoot = join(standaloneRoot, 'web')
const standaloneNodeModulesRoot = join(standaloneRoot, 'node_modules')
const staticRoot = join(webRoot, '.next', 'static')
const publicRoot = join(webRoot, 'public')
const distWebRoot = join(root, 'dist', 'web')
const distStandaloneRoot = join(distWebRoot, 'standalone')
const sourceNodePtyRoot = join(webRoot, 'node_modules', 'node-pty')
const COPY_OPTIONS = {
recursive: true,
force: true,
dereference: true,
}
function overlayNodePty(targetRoot) {
if (!existsSync(sourceNodePtyRoot)) return []
const hydrated = []
const directTarget = join(targetRoot, 'node_modules', 'node-pty')
mkdirSync(join(targetRoot, 'node_modules'), { recursive: true })
cpSync(sourceNodePtyRoot, directTarget, COPY_OPTIONS)
hydrated.push(directTarget)
const hashedNodeModulesRoot = join(targetRoot, '.next', 'node_modules')
if (!existsSync(hashedNodeModulesRoot)) return hydrated
for (const entry of readdirSync(hashedNodeModulesRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('node-pty-')) continue
const target = join(hashedNodeModulesRoot, entry.name)
cpSync(sourceNodePtyRoot, target, COPY_OPTIONS)
hydrated.push(target)
}
return hydrated
}
if (!existsSync(standaloneAppRoot)) {
console.error('[gsd] Web standalone build not found at web/.next/standalone/web. Run `npm --prefix web run build` first.')
process.exit(1)
}
rmSync(distWebRoot, { recursive: true, force: true })
mkdirSync(distStandaloneRoot, { recursive: true })
cpSync(standaloneAppRoot, distStandaloneRoot, COPY_OPTIONS)
if (existsSync(standaloneNodeModulesRoot)) {
cpSync(standaloneNodeModulesRoot, join(distStandaloneRoot, 'node_modules'), COPY_OPTIONS)
}
if (existsSync(staticRoot)) {
mkdirSync(join(distStandaloneRoot, '.next'), { recursive: true })
cpSync(staticRoot, join(distStandaloneRoot, '.next', 'static'), COPY_OPTIONS)
}
if (existsSync(publicRoot)) {
cpSync(publicRoot, join(distStandaloneRoot, 'public'), COPY_OPTIONS)
}
const hydratedTargets = overlayNodePty(distStandaloneRoot)
console.log(`[gsd] Staged web standalone host at ${distStandaloneRoot}`)
if (hydratedTargets.length > 0) {
console.log(`[gsd] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`)
}

View file

@ -66,6 +66,7 @@ try {
'dist/loader.js',
'packages/pi-coding-agent/dist/index.js',
'scripts/link-workspace-packages.cjs',
'dist/web/standalone/server.js',
];
let missing = false;

8
src/app-paths.js Normal file
View file

@ -0,0 +1,8 @@
import { homedir } from 'os'
import { join } from 'path'
export const appRoot = join(homedir(), '.gsd')
export const agentDir = join(appRoot, 'agent')
export const sessionsDir = join(appRoot, 'sessions')
export const authFilePath = join(agentDir, 'auth.json')
export const webPidFilePath = join(appRoot, 'web-server.pid')

View file

@ -5,3 +5,5 @@ export const appRoot = process.env.GSD_HOME || join(homedir(), '.gsd')
export const agentDir = join(appRoot, 'agent')
export const sessionsDir = join(appRoot, 'sessions')
export const authFilePath = join(agentDir, 'auth.json')
export const webPidFilePath = join(appRoot, 'web-server.pid')
export const webPreferencesPath = join(appRoot, 'web-preferences.json')

306
src/cli-web-branch.ts Normal file
View file

@ -0,0 +1,306 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { agentDir as defaultAgentDir, sessionsDir as defaultSessionsDir, webPreferencesPath as defaultWebPreferencesPath } from './app-paths.js'
import { getProjectSessionsDir } from './project-sessions.js'
import { launchWebMode, stopWebMode, type WebModeLaunchStatus, type WebModeStopOptions, type WebModeStopResult } from './web-mode.js'
export interface CliFlags {
mode?: 'text' | 'json' | 'rpc'
print?: boolean
continue?: boolean
noSession?: boolean
model?: string
listModels?: string | true
extensions: string[]
appendSystemPrompt?: string
tools?: string[]
messages: string[]
web?: boolean
/** Optional project path for web mode: `gsd --web <path>` or `gsd web start <path>` */
webPath?: string
/** Custom host to bind web server to: `--host 0.0.0.0` */
webHost?: string
/** Custom port for web server: `--port 8080` */
webPort?: number
/** Additional allowed origins for CORS: `--allowed-origins http://192.168.1.10:8080` */
webAllowedOrigins?: string[]
help?: boolean
version?: boolean
}
type WritableLike = Pick<typeof process.stderr, 'write'>
export interface RunWebCliBranchDeps {
runWebMode?: typeof launchWebMode
stopWebMode?: (deps: Parameters<typeof stopWebMode>[0], options?: WebModeStopOptions) => WebModeStopResult
cwd?: () => string
stderr?: WritableLike
baseSessionsDir?: string
agentDir?: string
webPreferencesPath?: string
}
export function parseCliArgs(argv: string[]): CliFlags {
const flags: CliFlags = { extensions: [], messages: [] }
const args = argv.slice(2)
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--mode' && i + 1 < args.length) {
const mode = args[++i]
if (mode === 'text' || mode === 'json' || mode === 'rpc') flags.mode = mode
} else if (arg === '--print' || arg === '-p') {
flags.print = true
} else if (arg === '--continue' || arg === '-c') {
flags.continue = true
} else if (arg === '--no-session') {
flags.noSession = true
} else if (arg === '--web') {
flags.web = true
// Peek at next arg — if it looks like a path (not another flag), capture it
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
flags.webPath = args[++i]
}
} else if (arg === '--host' && i + 1 < args.length) {
flags.webHost = args[++i]
} else if (arg === '--port' && i + 1 < args.length) {
const portStr = args[++i]
const port = parseInt(portStr, 10)
if (Number.isFinite(port) && port > 0 && port < 65536) {
flags.webPort = port
}
} else if (arg === '--allowed-origins' && i + 1 < args.length) {
const origins = args[++i].split(',').map(o => o.trim()).filter(Boolean)
flags.webAllowedOrigins = (flags.webAllowedOrigins ?? []).concat(origins)
} else if (arg === '--model' && i + 1 < args.length) {
flags.model = args[++i]
} else if (arg === '--extension' && i + 1 < args.length) {
flags.extensions.push(args[++i])
} else if (arg === '--append-system-prompt' && i + 1 < args.length) {
flags.appendSystemPrompt = args[++i]
} else if (arg === '--tools' && i + 1 < args.length) {
flags.tools = args[++i].split(',')
} else if (arg === '--list-models') {
flags.listModels = (i + 1 < args.length && !args[i + 1].startsWith('-')) ? args[++i] : true
} else if (arg === '--version' || arg === '-v') {
flags.version = true
} else if (arg === '--help' || arg === '-h') {
flags.help = true
} else if (!arg.startsWith('--') && !arg.startsWith('-')) {
flags.messages.push(arg)
}
}
return flags
}
export { getProjectSessionsDir } from './project-sessions.js'
export function migrateLegacyFlatSessions(baseSessionsDir: string, projectSessionsDir: string): void {
if (!existsSync(baseSessionsDir)) return
try {
const entries = readdirSync(baseSessionsDir)
const flatJsonl = entries.filter((file) => file.endsWith('.jsonl'))
if (flatJsonl.length === 0) return
mkdirSync(projectSessionsDir, { recursive: true })
for (const file of flatJsonl) {
const src = join(baseSessionsDir, file)
const dst = join(projectSessionsDir, file)
if (!existsSync(dst)) {
renameSync(src, dst)
}
}
} catch {
// Non-fatal — don't block startup if migration fails
}
}
function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void {
if (status.ok) return
stderr.write(`[gsd] Web mode launch failed: ${status.failureReason}\n`)
}
/**
* Resolve the working directory for context-aware launch detection.
*
* If the user has configured a dev root via onboarding and their cwd is inside
* a project under that dev root, return the one-level-deep project directory.
* Otherwise, return the cwd unchanged (browser picker handles selection).
*
* Edge cases handled:
* - Missing or unreadable prefs file cwd unchanged
* - No devRoot field in prefs cwd unchanged
* - devRoot path doesn't exist (stale) cwd unchanged
* - cwd IS the devRoot cwd unchanged (picker selects)
* - cwd outside devRoot cwd unchanged
*/
export function resolveContextAwareCwd(currentCwd: string, prefsPath: string): string {
// 1. Read preferences file
let prefs: Record<string, unknown>
try {
const raw = readFileSync(prefsPath, 'utf-8')
prefs = JSON.parse(raw)
} catch {
return currentCwd
}
// 2. Extract devRoot
const devRoot = prefs.devRoot
if (typeof devRoot !== 'string' || !devRoot) {
return currentCwd
}
// 3. Resolve both paths to absolute
const resolvedCwd = resolve(currentCwd)
const resolvedDevRoot = resolve(devRoot)
// 4. Check devRoot still exists
if (!existsSync(resolvedDevRoot)) {
return currentCwd
}
// 5. If cwd IS the devRoot → unchanged (picker handles selection)
if (resolvedCwd === resolvedDevRoot) {
return currentCwd
}
// 6. If cwd is inside devRoot, extract one-level-deep project directory
const prefix = resolvedDevRoot + sep
if (resolvedCwd.startsWith(prefix)) {
const relative = resolvedCwd.slice(prefix.length)
const firstSegment = relative.split(sep)[0]
if (firstSegment) {
return join(resolvedDevRoot, firstSegment)
}
}
// 7. cwd outside devRoot → unchanged
return currentCwd
}
export type RunWebCliBranchResult =
| { handled: false }
| {
handled: true
exitCode: number
action: 'start'
status: WebModeLaunchStatus
launchInputs: { cwd: string; projectSessionsDir: string; agentDir: string }
}
| {
handled: true
exitCode: number
action: 'stop'
stopResult: WebModeStopResult
}
export async function runWebCliBranch(
flags: CliFlags,
deps: RunWebCliBranchDeps = {},
): Promise<RunWebCliBranchResult> {
// Handle `gsd web stop [path|--all]` subcommand
if (flags.messages[0] === 'web' && flags.messages[1] === 'stop') {
const stderr = deps.stderr ?? process.stderr
const stopArg = flags.messages[2]
const isAll = stopArg === 'all'
const stopCwd = stopArg && !isAll ? resolve((deps.cwd ?? (() => process.cwd()))(), stopArg) : undefined
const stopResult = (deps.stopWebMode ?? stopWebMode)({ stderr }, {
projectCwd: stopCwd,
all: isAll,
})
return {
handled: true,
exitCode: stopResult.ok ? 0 : 1,
action: 'stop',
stopResult,
}
}
// `gsd web [start] [path]` is an alias for `gsd --web [path]`
// Matches: `gsd web`, `gsd web start`, `gsd web start <path>`, `gsd web <path>`
const isWebSubcommand = flags.messages[0] === 'web' && flags.messages[1] !== 'stop'
if (!flags.web && !isWebSubcommand) {
return { handled: false }
}
const stderr = deps.stderr ?? process.stderr
const defaultCwd = (deps.cwd ?? (() => process.cwd()))()
// Resolve project path from multiple forms:
// gsd --web <path> → flags.webPath
// gsd web start <path> → messages[2]
// gsd web <path> → messages[1] (when not "start")
let webPath = flags.webPath
if (!webPath && isWebSubcommand) {
if (flags.messages[1] === 'start') {
webPath = flags.messages[2]
} else if (flags.messages[1]) {
webPath = flags.messages[1]
}
}
let currentCwd: string
if (webPath) {
currentCwd = resolve(defaultCwd, webPath)
const checkExists = existsSync
if (!checkExists(currentCwd)) {
stderr.write(`[gsd] Project path does not exist: ${currentCwd}\n`)
return {
handled: true,
exitCode: 1,
action: 'start',
status: {
mode: 'web',
ok: false,
cwd: currentCwd,
projectSessionsDir: '',
host: '127.0.0.1',
port: null,
url: null,
hostKind: 'unresolved',
hostPath: null,
hostRoot: null,
failureReason: `project path does not exist: ${currentCwd}`,
},
launchInputs: { cwd: currentCwd, projectSessionsDir: '', agentDir: deps.agentDir ?? defaultAgentDir },
}
}
stderr.write(`[gsd] Using project path: ${currentCwd}\n`)
} else {
currentCwd = defaultCwd
}
// Context-aware launch: if cwd is inside a project under the configured dev root,
// resolve to the project directory so the browser opens directly into it
currentCwd = resolveContextAwareCwd(currentCwd, deps.webPreferencesPath ?? defaultWebPreferencesPath)
const baseSessionsDir = deps.baseSessionsDir ?? defaultSessionsDir
const agentDir = deps.agentDir ?? defaultAgentDir
const projectSessionsDir = getProjectSessionsDir(currentCwd, baseSessionsDir)
migrateLegacyFlatSessions(baseSessionsDir, projectSessionsDir)
const status = await (deps.runWebMode ?? launchWebMode)({
cwd: currentCwd,
projectSessionsDir,
agentDir,
host: flags.webHost,
port: flags.webPort,
allowedOrigins: flags.webAllowedOrigins,
})
if (!status.ok) {
emitWebModeFailure(stderr, status)
}
return {
handled: true,
exitCode: status.ok ? 0 : 1,
action: 'start',
status,
launchInputs: {
cwd: currentCwd,
projectSessionsDir,
agentDir,
},
}
}

View file

@ -9,7 +9,7 @@ import {
runPrintMode,
runRpcMode,
} from '@gsd/pi-coding-agent'
import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js'
@ -20,6 +20,13 @@ import { shouldRunOnboarding, runOnboarding } from './onboarding.js'
import chalk from 'chalk'
import { checkForUpdates } from './update-check.js'
import { printHelp, printSubcommandHelp } from './help-text.js'
import {
parseCliArgs as parseWebCliArgs,
runWebCliBranch,
migrateLegacyFlatSessions,
} from './cli-web-branch.js'
import { stopWebMode } from './web-mode.js'
import { getProjectSessionsDir } from './project-sessions.js'
import { markStartup, printStartupTimings } from './startup-timings.js'
// ---------------------------------------------------------------------------
@ -37,6 +44,9 @@ interface CliFlags {
appendSystemPrompt?: string
tools?: string[]
messages: string[]
web?: boolean
webPath?: string
/** Set by `gsd sessions` when the user picks a specific session to resume */
_selectedSessionPath?: string
}
@ -93,6 +103,12 @@ function parseCliArgs(argv: string[]): CliFlags {
} else if (arg === '--help' || arg === '-h') {
printHelp(process.env.GSD_VERSION || '0.0.0')
process.exit(0)
} else if (arg === '--web') {
flags.web = true
// Capture optional project path after --web (not a flag)
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
flags.webPath = args[++i]
}
} else if (!arg.startsWith('--') && !arg.startsWith('-')) {
flags.messages.push(arg)
}
@ -110,7 +126,7 @@ exitIfManagedResourcesAreNewer(agentDir)
// Early TTY check — must come before heavy initialization to avoid dangling
// handles that prevent process.exit() from completing promptly.
const hasSubcommand = cliFlags.messages.length > 0
if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels) {
if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels && !cliFlags.web) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
@ -143,6 +159,34 @@ if (cliFlags.messages[0] === 'update') {
process.exit(0)
}
// `gsd web stop [path|all]` — stop web server before anything else
if (cliFlags.messages[0] === 'web' && cliFlags.messages[1] === 'stop') {
const webFlags = parseWebCliArgs(process.argv)
const webBranch = await runWebCliBranch(webFlags, {
stopWebMode,
stderr: process.stderr,
baseSessionsDir: sessionsDir,
agentDir,
})
if (webBranch.handled) {
process.exit(webBranch.exitCode)
}
}
// `gsd --web [path]` or `gsd web [start] [path]` — launch browser-only web mode
if (cliFlags.web || (cliFlags.messages[0] === 'web' && cliFlags.messages[1] !== 'stop')) {
const webFlags = parseWebCliArgs(process.argv)
const webBranch = await runWebCliBranch(webFlags, {
stderr: process.stderr,
baseSessionsDir: sessionsDir,
agentDir,
})
if (webBranch.handled) {
process.exit(webBranch.exitCode)
}
}
// `gsd sessions` — list past sessions and pick one to resume
if (cliFlags.messages[0] === 'sessions') {
const cwd = process.cwd()
@ -478,31 +522,12 @@ if (!cliFlags.worktree && !isPrintMode) {
// Per-directory session storage — same encoding as the upstream SDK so that
// /resume only shows sessions from the current working directory.
const cwd = process.cwd()
const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`
const projectSessionsDir = join(sessionsDir, safePath)
const projectSessionsDir = getProjectSessionsDir(cwd)
// Migrate legacy flat sessions: before per-directory scoping, all .jsonl session
// files lived directly in ~/.gsd/sessions/. Move them into the correct per-cwd
// subdirectory so /resume can find them.
if (existsSync(sessionsDir)) {
try {
const entries = readdirSync(sessionsDir)
const flatJsonl = entries.filter(f => f.endsWith('.jsonl'))
if (flatJsonl.length > 0) {
const { mkdirSync } = await import('node:fs')
mkdirSync(projectSessionsDir, { recursive: true })
for (const file of flatJsonl) {
const src = join(sessionsDir, file)
const dst = join(projectSessionsDir, file)
if (!existsSync(dst)) {
renameSync(src, dst)
}
}
}
} catch {
// Non-fatal — don't block startup if migration fails
}
}
migrateLegacyFlatSessions(sessionsDir, projectSessionsDir)
const sessionManager = cliFlags._selectedSessionPath
? SessionManager.open(cliFlags._selectedSessionPath, projectSessionsDir)
@ -577,6 +602,17 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
}
}
if (!process.stdin.isTTY) {
process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n')
process.stderr.write('[gsd] Non-interactive alternatives:\n')
process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n')
process.stderr.write('[gsd] gsd --web [path] Browser-only web mode\n')
process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n')
process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n')
process.exit(1)
}
// Welcome screen — shown on every fresh interactive session before TUI takes over
{
const { printWelcomeScreen } = await import('./welcome-screen.js')

View file

@ -124,7 +124,8 @@ async function loadPico(): Promise<PicoModule> {
/** Open a URL in the system browser (best-effort, non-blocking) */
function openBrowser(url: string): void {
if (process.platform === 'win32') {
execFile('cmd', ['/c', 'start', '', url], () => {})
// PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not.
execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], () => {})
} else {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
execFile(cmd, [url], () => {})

8
src/project-sessions.ts Normal file
View file

@ -0,0 +1,8 @@
import { join } from "node:path"
import { sessionsDir as defaultSessionsDir } from "./app-paths.js"
export function getProjectSessionsDir(cwd: string, baseSessionsDir = defaultSessionsDir): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
return join(baseSessionsDir, safePath)
}

View file

@ -271,17 +271,27 @@ function ensureNodeModulesSymlink(agentDir: string): void {
const gsdNodeModules = join(packageRoot, 'node_modules')
try {
const existing = readlinkSync(agentNodeModules)
if (existing === gsdNodeModules) return // already correct
unlinkSync(agentNodeModules)
const stat = lstatSync(agentNodeModules)
if (stat.isSymbolicLink()) {
const existing = readlinkSync(agentNodeModules)
// Symlink exists — verify it points to the correct, existing target
if (existing === gsdNodeModules && existsSync(agentNodeModules)) return // correct and target exists
// Stale or wrong target — remove and recreate
unlinkSync(agentNodeModules)
} else {
// Real directory (not a symlink) is blocking — remove it
rmSync(agentNodeModules, { recursive: true, force: true })
}
} catch {
// readlinkSync throws if path doesn't exist or isn't a symlink — both are fine
// lstatSync throws if path doesn't exist — that's fine, we'll create below
}
try {
symlinkSync(gsdNodeModules, agentNodeModules, 'junction')
} catch {
// Non-fatal — worst case, extensions fall back to NODE_PATH via jiti
} catch (err) {
// This failure makes GSD non-functional — extensions can't resolve @gsd/* packages
console.error(`[gsd] WARN: Failed to symlink ${agentNodeModules}${gsdNodeModules}: ${err instanceof Error ? err.message : err}`)
}
}
@ -323,11 +333,13 @@ function pruneRemovedBundledExtensions(
for (const prevFile of manifest.installedExtensionRootFiles) {
removeIfStale(prevFile)
}
} else {
// Fallback: explicitly remove known stale files from pre-manifest-tracking versions
// env-utils.js was moved from extensions/ root → gsd/ in v2.39.x (#1634)
removeIfStale('env-utils.js')
}
// Always remove known stale files regardless of manifest state.
// These were installed by pre-manifest versions so they may not appear in
// installedExtensionRootFiles even when a manifest exists.
// env-utils.js was moved from extensions/ root → gsd/ in v2.39.x (#1634)
removeIfStale('env-utils.js')
}
/**
@ -357,6 +369,11 @@ export function initResources(agentDir: string): void {
// up even when the version/hash match causes the full sync to be skipped.
pruneRemovedBundledExtensions(manifest, agentDir)
// Ensure ~/.gsd/agent/node_modules symlinks to GSD's node_modules on EVERY
// launch, not just during resource syncs. A stale/broken symlink makes ALL
// extensions fail to resolve @gsd/* packages, rendering GSD non-functional.
ensureNodeModulesSymlink(agentDir)
// Skip the full copy when both version AND content fingerprint match.
// Version-only checks miss same-version content changes (npm link dev workflow,
// hotfixes within a release). The content hash catches those at ~1ms cost.
@ -369,6 +386,8 @@ export function initResources(agentDir: string): void {
}
}
// Sync bundled resources — overwrite so updates land on next launch.
syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'))
syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'))
syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'))
@ -384,11 +403,6 @@ export function initResources(agentDir: string): void {
// overwrite them (covers extensions, agents, and skills in one walk).
makeTreeWritable(agentDir)
// Ensure ~/.gsd/agent/node_modules symlinks to GSD's node_modules so that
// native ESM import() calls from synced extension files can resolve @gsd/*
// packages via ancestor directory lookup. NODE_PATH only applies to CJS/jiti.
ensureNodeModulesSymlink(agentDir)
writeManagedResourceManifest(agentDir)
ensureRegistryEntries(join(agentDir, 'extensions'))
}

View file

@ -18,7 +18,7 @@ import {
type Question,
type QuestionOption,
type RoundResult,
} from "./shared/mod.js";
} from "./shared/tui.js";
// ─── Types ────────────────────────────────────────────────────────────────────

View file

@ -67,6 +67,8 @@ export function createAsyncBashTool(
promptGuidelines: [
"Use async_bash for commands that take more than a few seconds (builds, tests, installs, large git operations).",
"After starting async jobs, continue with other work and use await_job when you need the results.",
"await_job has a configurable timeout (default 120s) to prevent indefinite blocking — if it times out, jobs keep running and you can check again later.",
"For long-running processes (SSH, deploys, training) that may take minutes+, prefer async_bash with periodic await_job polling over a single long await.",
"Use cancel_job to stop a running background job.",
"Check /jobs to see all running and recent background jobs.",
],

View file

@ -0,0 +1,120 @@
/**
* await-tool.test.ts Tests for await_job timeout behavior.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { AsyncJobManager } from "./job-manager.ts";
import { createAwaitTool } from "./await-tool.ts";
function getTextFromResult(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.map((c) => c.text ?? "").join("\n");
}
const noopSignal = new AbortController().signal;
test("await_job returns immediately when no running jobs exist", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
const result = await tool.execute("tc1", {}, noopSignal, () => {}, undefined as never);
const text = getTextFromResult(result);
assert.match(text, /No running background jobs/);
});
test("await_job returns immediately when all watched jobs are already completed", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
// Register a job that completes instantly
const jobId = manager.register("bash", "fast-job", async () => "done");
// Wait for the job to settle
const job = manager.getJob(jobId)!;
await job.promise;
const result = await tool.execute("tc2", { jobs: [jobId] }, noopSignal, () => {}, undefined as never);
const text = getTextFromResult(result);
assert.match(text, /fast-job/);
assert.match(text, /completed/);
});
test("await_job returns on timeout when jobs are still running", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
// Register a job that takes a long time
const jobId = manager.register("bash", "slow-job", async (_signal) => {
return new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("finally done"), 60_000);
if (typeof timer === "object" && "unref" in timer) timer.unref();
});
});
const start = Date.now();
const result = await tool.execute("tc3", { jobs: [jobId], timeout: 1 }, noopSignal, () => {}, undefined as never);
const elapsed = Date.now() - start;
const text = getTextFromResult(result);
// Should have timed out within ~1-2 seconds, not 60
assert.ok(elapsed < 5_000, `Expected timeout in ~1s but took ${elapsed}ms`);
assert.match(text, /Timed out/);
assert.match(text, /Still running/);
assert.match(text, /slow-job/);
// Cleanup
manager.cancel(jobId);
manager.shutdown();
});
test("await_job completes before timeout when job finishes quickly", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
// Register a job that completes in 100ms
const jobId = manager.register("bash", "quick-job", async () => {
return new Promise<string>((resolve) => setTimeout(() => resolve("quick result"), 100));
});
const start = Date.now();
const result = await tool.execute("tc4", { jobs: [jobId], timeout: 30 }, noopSignal, () => {}, undefined as never);
const elapsed = Date.now() - start;
const text = getTextFromResult(result);
// Should complete in ~100ms, well before the 30s timeout
assert.ok(elapsed < 5_000, `Expected quick completion but took ${elapsed}ms`);
assert.ok(!text.includes("Timed out"), "Should not have timed out");
assert.match(text, /quick-job/);
assert.match(text, /completed/);
manager.shutdown();
});
test("await_job uses default timeout of 120s when not specified", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
// Register a job that completes immediately
const jobId = manager.register("bash", "instant-job", async () => "instant");
const job = manager.getJob(jobId)!;
await job.promise;
// Call without timeout param — should work fine for already-done jobs
const result = await tool.execute("tc5", { jobs: [jobId] }, noopSignal, () => {}, undefined as never);
const text = getTextFromResult(result);
assert.match(text, /instant-job/);
assert.match(text, /completed/);
manager.shutdown();
});
test("await_job returns not-found message for invalid job IDs", async () => {
const manager = new AsyncJobManager();
const tool = createAwaitTool(() => manager);
const result = await tool.execute("tc6", { jobs: ["bg_nonexistent"] }, noopSignal, () => {}, undefined as never);
const text = getTextFromResult(result);
assert.match(text, /No jobs found/);
assert.match(text, /bg_nonexistent/);
manager.shutdown();
});

View file

@ -9,12 +9,21 @@ import type { ToolDefinition } from "@gsd/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import type { AsyncJobManager, Job } from "./job-manager.js";
const DEFAULT_TIMEOUT_SECONDS = 120;
const schema = Type.Object({
jobs: Type.Optional(
Type.Array(Type.String(), {
description: "Job IDs to wait for. Omit to wait for any running job.",
}),
),
timeout: Type.Optional(
Type.Number({
description:
"Maximum seconds to wait before returning control. Defaults to 120. " +
"Jobs continue running in the background after timeout.",
}),
),
});
export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefinition<typeof schema> {
@ -26,7 +35,8 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti
parameters: schema,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const manager = getManager();
const { jobs: jobIds } = params;
const { jobs: jobIds, timeout } = params;
const timeoutMs = ((timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000);
let watched: Job[];
if (jobIds && jobIds.length > 0) {
@ -63,8 +73,20 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti
return { content: [{ type: "text", text: result }], details: undefined };
}
// Wait for at least one to complete
await Promise.race(running.map((j) => j.promise));
// Wait for at least one to complete, or timeout
const TIMEOUT_SENTINEL = Symbol("timeout");
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs);
// Allow the process to exit even if the timer is pending
if (typeof timer === "object" && "unref" in timer) timer.unref();
});
const raceResult = await Promise.race([
Promise.race(running.map((j) => j.promise)).then(() => "completed" as const),
timeoutPromise,
]);
const timedOut = raceResult === TIMEOUT_SENTINEL;
// Collect all completed results (more may have finished while waiting)
const completed = watched.filter((j) => j.status !== "running");
@ -74,6 +96,11 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti
if (stillRunning.length > 0) {
result += `\n\n**Still running:** ${stillRunning.map((j) => `${j.id} (${j.label})`).join(", ")}`;
}
if (timedOut) {
result += `\n\n⏱ **Timed out** after ${timeout ?? DEFAULT_TIMEOUT_SECONDS}s waiting for jobs to finish. ` +
`Jobs are still running in the background. ` +
`Use \`await_job\` again later or \`async_bash\` + \`await_job\` for shorter polling intervals.`;
}
return { content: [{ type: "text", text: result }], details: undefined };
},

View file

@ -1,5 +1,7 @@
import { execFile, execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { GSDPreferences } from "../gsd/preferences.js";
import type { GSDState, Phase } from "../gsd/types.js";

View file

@ -13,7 +13,8 @@ import { resolve } from "node:path";
import type { ExtensionAPI, Theme } from "@gsd/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { makeUI, maskEditorLine, type ProgressStatus } from "./shared/mod.js";
import { makeUI } from "./shared/tui.js";
import { maskEditorLine, type ProgressStatus } from "./shared/mod.js";
import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
import { resolveMilestoneFile } from "./gsd/paths.js";
import type { SecretsManifestEntry } from "./gsd/types.js";
@ -234,7 +235,7 @@ export async function showSecretsSummary(
const existingSet = new Set(existingKeys);
await ctx.ui.custom((tui: any, theme: Theme, _kb: any, done: (r: null) => void) => {
await ctx.ui.custom((_tui: any, theme: Theme, _kb: any, done: (r: null) => void) => {
let cachedLines: string[] | undefined;
function handleInput(_data: string) {

View file

@ -1,21 +1,179 @@
import { writeFileSync, renameSync, unlinkSync, mkdirSync, promises as fs } from "node:fs"
import { dirname } from "node:path"
import { randomBytes } from "node:crypto"
import { writeFileSync, renameSync, unlinkSync, mkdirSync, promises as fs } from "node:fs";
import { dirname } from "node:path";
import { randomBytes } from "node:crypto";
const TRANSIENT_LOCK_ERROR_CODES = new Set(["EBUSY", "EPERM", "EACCES"]);
const MAX_RENAME_ATTEMPTS = 5;
const SYNC_SLEEP_BUFFER = new SharedArrayBuffer(4);
const SYNC_SLEEP_VIEW = new Int32Array(SYNC_SLEEP_BUFFER);
type RetryableEncoding = BufferEncoding;
type MkdirOptions = { recursive: true };
export interface AtomicWriteAsyncOps {
mkdir(path: string, options: MkdirOptions): Promise<void>;
writeFile(path: string, content: string, encoding: RetryableEncoding): Promise<void>;
rename(from: string, to: string): Promise<void>;
unlink(path: string): Promise<void>;
sleep(ms: number): Promise<void>;
createTempPath?(filePath: string): string;
}
export interface AtomicWriteSyncOps {
mkdir(path: string, options: MkdirOptions): void;
writeFile(path: string, content: string, encoding: RetryableEncoding): void;
rename(from: string, to: string): void;
unlink(path: string): void;
sleep(ms: number): void;
createTempPath?(filePath: string): string;
}
function defaultTempPath(filePath: string): string {
return filePath + `.tmp.${randomBytes(4).toString("hex")}`;
}
function computeRetryDelayMs(attempt: number): number {
const base = 8 * attempt;
const jitter = randomBytes(1)[0] % 5;
return base + jitter;
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function sleepSync(ms: number): void {
Atomics.wait(SYNC_SLEEP_VIEW, 0, 0, ms);
}
function normalizeErrnoCode(error: unknown): string | undefined {
if (error && typeof error === "object" && "code" in error) {
const code = (error as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
return undefined;
}
function isTransientLockError(error: unknown): boolean {
const code = normalizeErrnoCode(error);
return typeof code === "string" && TRANSIENT_LOCK_ERROR_CODES.has(code);
}
function buildAtomicWriteError(filePath: string, attempts: number, error: unknown): Error {
const code = normalizeErrnoCode(error) ?? "UNKNOWN";
const message = error instanceof Error ? error.message : String(error);
const wrapped = new Error(
`Atomic write to ${filePath} failed after ${attempts} attempts (last error code: ${code}): ${message}`,
) as NodeJS.ErrnoException;
wrapped.code = code;
if (error instanceof Error && "stack" in error && error.stack) {
wrapped.stack = error.stack;
}
return wrapped;
}
async function cleanupTempFileAsync(tmpPath: string, ops: AtomicWriteAsyncOps): Promise<void> {
try {
await ops.unlink(tmpPath);
} catch {
// Best-effort cleanup only.
}
}
function cleanupTempFileSync(tmpPath: string, ops: AtomicWriteSyncOps): void {
try {
ops.unlink(tmpPath);
} catch {
// Best-effort cleanup only.
}
}
/** @internal Exported for retry/cleanup tests. */
export async function atomicWriteAsyncWithOps(
filePath: string,
content: string,
encoding: RetryableEncoding = "utf-8",
ops: AtomicWriteAsyncOps,
): Promise<void> {
await ops.mkdir(dirname(filePath), { recursive: true });
const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath);
await ops.writeFile(tmpPath, content, encoding);
let lastError: unknown = null;
let attempts = 0;
for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) {
try {
await ops.rename(tmpPath, filePath);
return;
} catch (error) {
lastError = error;
if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) {
break;
}
await ops.sleep(computeRetryDelayMs(attempts));
}
}
await cleanupTempFileAsync(tmpPath, ops);
throw buildAtomicWriteError(filePath, attempts, lastError);
}
/** @internal Exported for retry/cleanup tests. */
export function atomicWriteSyncWithOps(
filePath: string,
content: string,
encoding: RetryableEncoding = "utf-8",
ops: AtomicWriteSyncOps,
): void {
ops.mkdir(dirname(filePath), { recursive: true });
const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath);
ops.writeFile(tmpPath, content, encoding);
let lastError: unknown = null;
let attempts = 0;
for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) {
try {
ops.rename(tmpPath, filePath);
return;
} catch (error) {
lastError = error;
if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) {
break;
}
ops.sleep(computeRetryDelayMs(attempts));
}
}
cleanupTempFileSync(tmpPath, ops);
throw buildAtomicWriteError(filePath, attempts, lastError);
}
const DEFAULT_ASYNC_OPS: AtomicWriteAsyncOps = {
mkdir: async (path, options) => {
await fs.mkdir(path, options);
},
writeFile: (path, content, encoding) => fs.writeFile(path, content, encoding),
rename: (from, to) => fs.rename(from, to),
unlink: (path) => fs.unlink(path),
sleep: delay,
};
const DEFAULT_SYNC_OPS: AtomicWriteSyncOps = {
mkdir: (path, options) => mkdirSync(path, options),
writeFile: (path, content, encoding) => writeFileSync(path, content, encoding),
rename: (from, to) => renameSync(from, to),
unlink: (path) => unlinkSync(path),
sleep: sleepSync,
};
/**
* Atomically writes content to a file by writing to a temp file first,
* then renaming. Prevents partial/corrupt files on crash.
*/
export function atomicWriteSync(filePath: string, content: string, encoding: BufferEncoding = "utf-8"): void {
mkdirSync(dirname(filePath), { recursive: true })
const tmpPath = filePath + `.tmp.${randomBytes(4).toString("hex")}`
writeFileSync(tmpPath, content, encoding)
try {
renameSync(tmpPath, filePath)
} catch (err) {
try { unlinkSync(tmpPath) } catch { /* orphan cleanup best-effort */ }
throw err
}
return atomicWriteSyncWithOps(filePath, content, encoding, DEFAULT_SYNC_OPS);
}
/**
@ -23,13 +181,5 @@ export function atomicWriteSync(filePath: string, content: string, encoding: Buf
* by writing to a temp file first, then renaming.
*/
export async function atomicWriteAsync(filePath: string, content: string, encoding: BufferEncoding = "utf-8"): Promise<void> {
await fs.mkdir(dirname(filePath), { recursive: true })
const tmpPath = filePath + `.tmp.${randomBytes(4).toString("hex")}`
await fs.writeFile(tmpPath, content, encoding)
try {
await fs.rename(tmpPath, filePath)
} catch (err) {
await fs.unlink(tmpPath).catch(() => { /* orphan cleanup best-effort */ })
throw err
}
return atomicWriteAsyncWithOps(filePath, content, encoding, DEFAULT_ASYNC_OPS);
}

View file

@ -19,10 +19,24 @@ import { parseRoadmap, parsePlan } from "./files.js";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
import { makeUI } from "../shared/tui.js";
import { GLYPH, INDENT } from "../shared/mod.js";
import { computeProgressScore } from "./progress-score.js";
import { getActiveWorktreeName } from "./worktree-command.js";
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js";
// ─── UAT Slice Extraction ─────────────────────────────────────────────────────
/**
* Extract the target slice ID from a run-uat unit ID (e.g. "M001/S01" "S01").
* Returns null if the format doesn't match.
*/
export function extractUatSliceId(unitId: string): string | null {
const parts = unitId.split("/");
if (parts.length >= 2 && parts[1]!.startsWith("S")) return parts[1]!;
return null;
}
// ─── Dashboard Data ───────────────────────────────────────────────────────────
@ -54,6 +68,7 @@ export interface AutoDashboardData {
export function unitVerb(unitType: string): string {
if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`;
switch (unitType) {
case "discuss-milestone": return "discussing";
case "research-milestone":
case "research-slice": return "researching";
case "plan-milestone":
@ -71,6 +86,7 @@ export function unitVerb(unitType: string): string {
export function unitPhaseLabel(unitType: string): string {
if (unitType.startsWith("hook/")) return "HOOK";
switch (unitType) {
case "discuss-milestone": return "DISCUSS";
case "research-milestone": return "RESEARCH";
case "research-slice": return "RESEARCH";
case "plan-milestone": return "PLAN";
@ -95,6 +111,7 @@ function peekNext(unitType: string, state: GSDState): string {
const sid = state.activeSlice?.id ?? "";
if (unitType.startsWith("hook/")) return `continue ${sid}`;
switch (unitType) {
case "discuss-milestone": return "research or plan milestone";
case "research-milestone": return "plan milestone roadmap";
case "plan-milestone": return "plan or execute first slice";
case "research-slice": return `plan ${sid}`;
@ -142,8 +159,9 @@ export function describeNextUnit(state: GSDState): { label: string; description:
/** Format elapsed time since auto-mode started */
export function formatAutoElapsed(autoStartTime: number): string {
if (!autoStartTime) return "";
if (!autoStartTime || autoStartTime <= 0 || !Number.isFinite(autoStartTime)) return "";
const ms = Date.now() - autoStartTime;
if (ms < 0 || ms > 30 * 24 * 3600_000) return ""; // negative or >30 days = invalid
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
@ -289,7 +307,7 @@ function refreshLastCommit(basePath: string): void {
const sep = raw.indexOf("|");
if (sep > 0) {
cachedLastCommit = {
timeAgo: raw.slice(0, sep).replace(/ ago$/, "").replace(/ /g, ""),
timeAgo: raw.slice(0, sep).replace(/ ago$/, ""),
message: raw.slice(sep + 1),
};
}
@ -407,10 +425,17 @@ export function updateProgressWidget(
const verb = unitVerb(unitType);
const phaseLabel = unitPhaseLabel(unitType);
const mid = state.activeMilestone;
const slice = state.activeSlice;
const task = state.activeTask;
const isHook = unitType.startsWith("hook/");
// When run-uat is executing for a just-completed slice (e.g. S01),
// deriveState() has already advanced activeSlice to the next one (S02).
// Override the displayed slice to match the UAT target from the unit ID.
const uatTargetSliceId = unitType === "run-uat" ? extractUatSliceId(unitId) : null;
const slice = uatTargetSliceId
? { id: uatTargetSliceId, title: state.activeSlice?.title ?? "" }
: state.activeSlice;
const task = state.activeTask;
// Cache git branch at widget creation time (not per render)
let cachedBranch: string | null = null;
try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch { /* not in git repo */ }
@ -436,6 +461,9 @@ export function updateProgressWidget(
// Pre-fetch last commit for display
refreshLastCommit(accessors.getBasePath());
// Cache the effective service tier at widget creation time (reads preferences)
const effectiveServiceTier = getEffectiveServiceTier();
ctx.ui.setWidget("gsd-progress", (tui, theme) => {
let pulseBright = true;
let cachedLines: string[] | undefined;
@ -505,6 +533,20 @@ export function updateProgressWidget(
: "";
lines.push(rightAlign(headerLeft, headerRight, width));
// Show health signal details when degraded (yellow/red)
if (score.level !== "green" && score.signals.length > 0 && widgetMode !== "min") {
// Show up to 3 most relevant signals in compact form
const topSignals = score.signals
.filter(s => s.kind === "negative")
.slice(0, 3);
if (topSignals.length > 0) {
const signalStr = topSignals
.map(s => theme.fg("dim", s.label))
.join(theme.fg("dim", " · "));
lines.push(`${pad} ${signalStr}`);
}
}
// ── Gather stats (needed by multiple modes) ─────────────────────
const cmdCtx = accessors.getCmdCtx();
let totalInput = 0;
@ -534,9 +576,10 @@ export function updateProgressWidget(
// Model display — shown in context section, not stats
const modelId = cmdCtx?.model?.id ?? "";
const modelProvider = cmdCtx?.model?.provider ?? "";
const modelDisplay = modelProvider && modelId
const tierIcon = resolveServiceTierIcon(effectiveServiceTier, modelId);
const modelDisplay = (modelProvider && modelId
? `${modelProvider}/${modelId}`
: modelId;
: modelId) + (tierIcon ? ` ${tierIcon}` : "");
// ── Mode: off — return empty ──────────────────────────────────
if (widgetMode === "off") {

View file

@ -172,11 +172,23 @@ export async function dispatchDirectPhase(
case "uat":
case "run-uat": {
const sid = state.activeSlice?.id;
if (!sid) {
ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning");
// UAT targets the most recently completed slice, not the active (next
// incomplete) slice. After slice completion, state.activeSlice advances
// to the next incomplete slice, so we find the last done slice from the
// roadmap instead (#1693).
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) {
ctx.ui.notify("Cannot dispatch run-uat: no roadmap found.", "warning");
return;
}
const roadmap = parseRoadmap(roadmapContent);
const completedSlices = roadmap.slices.filter(s => s.done);
if (completedSlices.length === 0) {
ctx.ui.notify("Cannot dispatch run-uat: no completed slices.", "warning");
return;
}
const sid = completedSlices[completedSlices.length - 1].id;
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
if (!uatFile) {
ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");

View file

@ -25,7 +25,9 @@ import {
} from "./paths.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { hasImplementationArtifacts } from "./auto-recovery.js";
import {
buildDiscussMilestonePrompt,
buildResearchMilestonePrompt,
buildPlanMilestonePrompt,
buildResearchSlicePrompt,
@ -52,9 +54,11 @@ export type DispatchAction =
unitId: string;
prompt: string;
pauseAfterDispatch?: boolean;
/** Name of the matched dispatch rule from the unified registry (journal provenance). */
matchedRule?: string;
}
| { action: "stop"; reason: string; level: "info" | "warning" | "error" }
| { action: "skip" };
| { action: "stop"; reason: string; level: "info" | "warning" | "error"; matchedRule?: string }
| { action: "skip"; matchedRule?: string };
export interface DispatchContext {
basePath: string;
@ -65,7 +69,7 @@ export interface DispatchContext {
session?: import("./auto/session.js").AutoSession;
}
interface DispatchRule {
export interface DispatchRule {
/** Human-readable name for debugging and test identification */
name: string;
/** Return a DispatchAction if this rule matches, null to fall through */
@ -86,7 +90,7 @@ const MAX_REWRITE_ATTEMPTS = 3;
// ─── Rules ────────────────────────────────────────────────────────────────
const DISPATCH_RULES: DispatchRule[] = [
export const DISPATCH_RULES: DispatchRule[] = [
{
name: "rewrite-docs (override gate)",
match: async ({ mid, midTitle, state, basePath, session }) => {
@ -159,6 +163,35 @@ const DISPATCH_RULES: DispatchRule[] = [
};
},
},
{
name: "uat-verdict-gate (non-PASS blocks progression)",
match: async ({ mid, basePath, prefs }) => {
// Only applies when UAT dispatch is enabled
if (!prefs?.uat_dispatch) return null;
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (!roadmapContent) return null;
const roadmap = parseRoadmap(roadmapContent);
for (const slice of roadmap.slices.filter(s => s.done)) {
const resultFile = resolveSliceFile(basePath, mid, slice.id, "UAT-RESULT");
if (!resultFile) continue;
const content = await loadFile(resultFile);
if (!content) continue;
const verdictMatch = content.match(/verdict:\s*([\w-]+)/i);
const verdict = verdictMatch?.[1]?.toLowerCase();
if (verdict && verdict !== "pass" && verdict !== "passed") {
return {
action: "stop" as const,
reason: `UAT verdict for ${slice.id} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`,
level: "warning" as const,
};
}
}
return null;
},
},
{
name: "reassess-roadmap (post-completion)",
match: async ({ state, mid, midTitle, basePath, prefs }) => {
@ -180,27 +213,29 @@ const DISPATCH_RULES: DispatchRule[] = [
},
},
{
name: "needs-discussion → stop",
match: async ({ state, mid, midTitle }) => {
name: "needs-discussion → discuss-milestone",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "needs-discussion") return null;
return {
action: "stop",
reason: `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
level: "warning",
action: "dispatch",
unitType: "discuss-milestone",
unitId: mid,
prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath),
};
},
},
{
name: "pre-planning (no context) → stop",
match: async ({ state, mid, basePath }) => {
name: "pre-planning (no context) → discuss-milestone",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "pre-planning") return null;
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (hasContext) return null; // fall through to next rule
return {
action: "stop",
reason: "No context or roadmap yet. Run /gsd to discuss first.",
level: "warning",
action: "dispatch",
unitType: "discuss-milestone",
unitId: mid,
prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath),
};
},
},
@ -543,6 +578,17 @@ const DISPATCH_RULES: DispatchRule[] = [
}
}
// Safety guard (#1703): verify the milestone produced implementation
// artifacts (non-.gsd/ files). A milestone with only plan files and
// zero implementation code should not be marked complete.
if (!hasImplementationArtifacts(basePath)) {
return {
action: "stop",
reason: `Cannot complete milestone ${mid}: no implementation files found outside .gsd/. The milestone has only plan files — actual code changes are required.`,
level: "error",
};
}
return {
action: "dispatch",
unitType: "complete-milestone",
@ -564,18 +610,35 @@ const DISPATCH_RULES: DispatchRule[] = [
},
];
import { getRegistry } from "./rule-registry.js";
// ─── Resolver ─────────────────────────────────────────────────────────────
/**
* Evaluate dispatch rules in order. Returns the first matching action,
* or a "stop" action if no rule matches (unhandled phase).
*
* Delegates to the RuleRegistry when initialized; falls back to inline
* loop over DISPATCH_RULES for backward compatibility (tests that import
* resolveDispatch directly without registry initialization).
*/
export async function resolveDispatch(
ctx: DispatchContext,
): Promise<DispatchAction> {
// Delegate to registry when available
try {
const registry = getRegistry();
return await registry.evaluateDispatch(ctx);
} catch {
// Registry not initialized — fall back to inline loop
}
for (const rule of DISPATCH_RULES) {
const result = await rule.match(ctx);
if (result) return result;
if (result) {
if (result.action !== "skip") result.matchedRule = rule.name;
return result;
}
}
// No rule matched — unhandled phase
@ -583,6 +646,7 @@ export async function resolveDispatch(
action: "stop",
reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`,
level: "info",
matchedRule: "<no-match>",
};
}

File diff suppressed because it is too large Load diff

View file

@ -164,7 +164,7 @@ export async function selectAndApplyModel(
* Resolve a model ID string to a model object from the available models list.
* Handles formats: "provider/model", "bare-id", "org/model-name" (OpenRouter).
*/
function resolveModelId<T extends { id: string; provider: string }>(
export function resolveModelId<T extends { id: string; provider: string }>(
modelId: string,
availableModels: T[],
currentProvider: string | undefined,

View file

@ -19,6 +19,9 @@ import {
resolveSliceFile,
resolveTaskFile,
resolveMilestoneFile,
resolveTasksDir,
buildTaskFileName,
gsdRoot,
} from "./paths.js";
import { invalidateAllCaches } from "./cache.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
@ -28,6 +31,7 @@ import {
} from "./worktree.js";
import {
verifyExpectedArtifact,
resolveExpectedArtifactPath,
} from "./auto-recovery.js";
import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js";
import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
@ -40,6 +44,7 @@ import {
isRetryPending,
consumeRetryTrigger,
persistHookState,
resolveHookArtifactPath,
} from "./post-unit-hooks.js";
import { hasPendingCaptures, loadPendingCaptures } from "./captures.js";
import { debugLog } from "./debug-logger.js";
@ -50,7 +55,11 @@ import {
unitVerb,
hideFooter,
} from "./auto-dashboard.js";
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { uncheckTaskInPlan } from "./undo.js";
import { atomicWriteSync } from "./atomic-write.js";
import { _resetHasChangesCache } from "./native-git-bridge.js";
/** Throttle STATE.md rebuilds — at most once per 30 seconds */
const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
@ -77,9 +86,12 @@ export interface PostUnitContext {
* Pre-verification processing: parallel worker signal check, cache invalidation,
* auto-commit, doctor run, state rebuild, worktree sync, artifact verification.
*
* Returns "dispatched" if a signal caused stop/pause, "continue" to proceed.
* Returns:
* - "dispatched" a signal caused stop/pause
* - "continue" proceed normally
* - "retry" artifact verification failed, s.pendingVerificationRetry set for loop re-iteration
*/
export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreVerificationOpts): Promise<"dispatched" | "continue"> {
export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreVerificationOpts): Promise<"dispatched" | "continue" | "retry"> {
const { s, ctx, pi, buildSnapshotOpts, stopAuto, pauseAuto } = pctx;
// ── Parallel worker signal check ──
@ -145,12 +157,20 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
}
}
// Invalidate the nativeHasChanges cache before auto-commit (#1853).
// The cache has a 10-second TTL and is keyed by basePath. A stale
// `false` result causes autoCommit to skip staging entirely, leaving
// code files only in the working tree where they are destroyed by
// `git worktree remove --force` during teardown.
_resetHasChangesCache();
const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext);
if (commitMsg) {
ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
}
} catch (e) {
debugLog("postUnit", { phase: "auto-commit", error: String(e) });
ctx.ui.notify(`Auto-commit failed: ${String(e).split("\n")[0]}`, "warning");
}
// GitHub sync (non-blocking, opt-in)
@ -241,6 +261,18 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
debugLog("postUnit", { phase: "prune-bg-shell", error: String(e) });
}
// Tear down browser between units to prevent Chrome process accumulation (#1733)
try {
const { getBrowser } = await import("../browser-tools/state.js");
if (getBrowser()) {
const { closeBrowser } = await import("../browser-tools/lifecycle.js");
await closeBrowser();
debugLog("postUnit", { phase: "browser-teardown", status: "closed" });
}
} catch (e) {
debugLog("postUnit", { phase: "browser-teardown", error: String(e) });
}
// Sync worktree state back to project root (skipped for lightweight sidecars)
if (!opts?.skipWorktreeSync && s.originalBasePath && s.originalBasePath !== s.basePath) {
try {
@ -280,36 +312,43 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
try {
const { executeTriageResolutions } = await import("./triage-resolution.js");
const state = await deriveState(s.basePath);
const mid = state.activeMilestone?.id;
const sid = state.activeSlice?.id;
const mid = state.activeMilestone?.id ?? "";
const sid = state.activeSlice?.id ?? "";
if (mid && sid) {
const triageResult = executeTriageResolutions(s.basePath, mid, sid);
// executeTriageResolutions handles defer milestone creation even
// without an active milestone/slice (the "all milestones complete"
// scenario from #1562). inject/replan/quick-task still require mid+sid.
const triageResult = executeTriageResolutions(s.basePath, mid, sid);
if (triageResult.injected > 0) {
ctx.ui.notify(
`Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`,
"info",
);
}
if (triageResult.replanned > 0) {
ctx.ui.notify(
`Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`,
"info",
);
}
if (triageResult.quickTasks.length > 0) {
for (const qt of triageResult.quickTasks) {
s.pendingQuickTasks.push(qt);
}
ctx.ui.notify(
`Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
"info",
);
}
for (const action of triageResult.actions) {
process.stderr.write(`gsd-triage: ${action}\n`);
if (triageResult.injected > 0) {
ctx.ui.notify(
`Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`,
"info",
);
}
if (triageResult.replanned > 0) {
ctx.ui.notify(
`Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`,
"info",
);
}
if (triageResult.deferredMilestones > 0) {
ctx.ui.notify(
`Triage: created ${triageResult.deferredMilestones} deferred milestone director${triageResult.deferredMilestones === 1 ? "y" : "ies"}.`,
"info",
);
}
if (triageResult.quickTasks.length > 0) {
for (const qt of triageResult.quickTasks) {
s.pendingQuickTasks.push(qt);
}
ctx.ui.notify(
`Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
"info",
);
}
for (const action of triageResult.actions) {
process.stderr.write(`gsd-triage: ${action}\n`);
}
} catch (err) {
process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`);
@ -327,6 +366,29 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
} catch (e) {
debugLog("postUnit", { phase: "artifact-verify", error: String(e) });
}
// When artifact verification fails for a unit type that has a known expected
// artifact, return "retry" so the caller re-dispatches with failure context
// instead of blindly re-dispatching the same unit (#1571).
if (!triggerArtifactVerified) {
const hasExpectedArtifact = resolveExpectedArtifactPath(s.currentUnit.type, s.currentUnit.id, s.basePath) !== null;
if (hasExpectedArtifact) {
const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`;
const attempt = (s.verificationRetryCount.get(retryKey) ?? 0) + 1;
s.verificationRetryCount.set(retryKey, attempt);
s.pendingVerificationRetry = {
unitId: s.currentUnit.id,
failureContext: `Artifact verification failed: expected artifact for ${s.currentUnit.type} "${s.currentUnit.id}" was not found on disk after unit execution (attempt ${attempt}).`,
attempt,
};
debugLog("postUnit", { phase: "artifact-verify-retry", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt });
ctx.ui.notify(
`Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — retrying (attempt ${attempt})`,
"warning",
);
return "retry";
}
}
} else {
// Hook unit completed — finalize its runtime record
try {
@ -403,9 +465,55 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
const trigger = consumeRetryTrigger();
if (trigger) {
ctx.ui.notify(
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
`Hook requested retry of ${trigger.unitType} ${trigger.unitId} — resetting task state.`,
"info",
);
// ── State reset: undo the completion so deriveState re-derives the unit ──
try {
const parts = trigger.unitId.split("/");
const [mid, sid, tid] = parts;
// 1. Uncheck [x] → [ ] in PLAN.md
if (mid && sid && tid) {
uncheckTaskInPlan(s.basePath, mid, sid, tid);
}
// 2. Delete SUMMARY.md for the task
if (mid && sid && tid) {
const tasksDir = resolveTasksDir(s.basePath, mid, sid);
if (tasksDir) {
const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY"));
if (existsSync(summaryFile)) {
unlinkSync(summaryFile);
}
}
}
// 3. Remove from s.completedUnits and flush to completed-units.json
s.completedUnits = s.completedUnits.filter(
u => !(u.type === trigger.unitType && u.id === trigger.unitId),
);
try {
const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
const keys = s.completedUnits.map(u => `${u.type}/${u.id}`);
atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
} catch { /* non-fatal: disk flush failure */ }
// 4. Delete the retry_on artifact (e.g. NEEDS-REWORK.md)
if (trigger.retryArtifact) {
const retryArtifactPath = resolveHookArtifactPath(s.basePath, trigger.unitId, trigger.retryArtifact);
if (existsSync(retryArtifactPath)) {
unlinkSync(retryArtifactPath);
}
}
// 5. Invalidate caches so deriveState reads fresh disk state
invalidateAllCaches();
} catch (e) {
debugLog("postUnitPostVerification", { phase: "retry-state-reset", error: String(e) });
}
// Fall through to normal dispatch — deriveState will re-derive the unit
}
}

View file

@ -424,7 +424,7 @@ export function buildSkillActivationBlock(params: {
params.taskPlanContent ?? undefined,
);
const visibleSkills = getLoadedSkills().filter(skill => !skill.disableModelInvocation);
const visibleSkills = (typeof getLoadedSkills === 'function' ? getLoadedSkills() : []).filter(skill => !skill.disableModelInvocation);
const installedNames = new Set(visibleSkills.map(skill => normalizeSkillReference(skill.name)));
const avoided = new Set(resolvePreferenceSkillNames(prefs?.avoid_skills ?? [], params.base));
const matched = new Set<string>();
@ -767,6 +767,34 @@ export async function checkNeedsRunUat(
// ─── Prompt Builders ──────────────────────────────────────────────────────
/**
* Build a prompt for the discuss-milestone unit type.
* Loads the guided-discuss-milestone template and inlines the CONTEXT-DRAFT
* as a seed when present. The discussion agent interviews the user, writes
* a full CONTEXT.md, and the phase transitions to pre-planning automatically.
*/
export async function buildDiscussMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
const discussTemplates = inlineTemplate("context", "Context");
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid,
milestoneTitle: midTitle,
inlinedTemplates: discussTemplates,
structuredQuestionsAvailable: "true",
commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally.",
});
// If a CONTEXT-DRAFT.md exists, append it as seed material
const draftPath = resolveMilestoneFile(base, mid, "CONTEXT-DRAFT");
const draftContent = draftPath ? await loadFile(draftPath) : null;
if (draftContent) {
return `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\nThe following draft was captured from a prior multi-milestone discussion. Use it as seed material — the user has already provided this context. Start with a brief reflection on what the draft covers, then probe for any gaps or open questions before writing the full CONTEXT.md.\n\n${draftContent}`;
}
return basePrompt;
}
export async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
const contextRel = relMilestoneFile(base, mid, "CONTEXT");

View file

@ -46,6 +46,7 @@ import {
writeFileSync,
unlinkSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
// ─── Artifact Resolution & Verification ───────────────────────────────────────
@ -62,6 +63,10 @@ export function resolveExpectedArtifactPath(
const mid = parts[0]!;
const sid = parts[1];
switch (unitType) {
case "discuss-milestone": {
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "CONTEXT")) : null;
}
case "research-milestone": {
const dir = resolveMilestonePath(base, mid);
return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
@ -119,6 +124,112 @@ export function resolveExpectedArtifactPath(
}
}
/**
* Check whether a milestone produced implementation artifacts (non-`.gsd/` files)
* in the git history. Uses `git log --name-only` to inspect all commits on the
* current branch that touch files outside `.gsd/`.
*
* Returns true if at least one non-`.gsd/` file was committed, false otherwise.
* Non-fatal: returns true on git errors to avoid blocking the pipeline when
* running outside a git repo (e.g., tests).
*/
export function hasImplementationArtifacts(basePath: string): boolean {
try {
// Verify we're in a git repo — fail open if not
try {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
} catch {
return true;
}
// Strategy: check `git diff --name-only` against the merge-base with the
// main branch. This captures ALL files changed during the milestone's
// lifetime. If no merge-base exists (e.g., single-branch workflow), fall
// back to checking the last N commits.
const mainBranch = detectMainBranch(basePath);
const changedFiles = getChangedFilesSinceBranch(basePath, mainBranch);
// No files changed at all — fail open (could be detached HEAD, single-
// commit repo, or other edge case where git diff returns nothing).
if (changedFiles.length === 0) return true;
// Filter out .gsd/ files — only implementation files count.
// If every changed file is under .gsd/, the milestone produced no
// implementation code (#1703).
const implFiles = changedFiles.filter(f => !f.startsWith(".gsd/") && !f.startsWith(".gsd\\"));
return implFiles.length > 0;
} catch {
// Non-fatal — if git operations fail, don't block the pipeline
return true;
}
}
/**
* Detect the main/master branch name.
*/
function detectMainBranch(basePath: string): string {
try {
const result = execFileSync("git", ["rev-parse", "--verify", "main"], {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
if (result.trim()) return "main";
} catch {
// main doesn't exist
}
try {
const result = execFileSync("git", ["rev-parse", "--verify", "master"], {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
if (result.trim()) return "master";
} catch {
// master doesn't exist either
}
return "main"; // default fallback
}
/**
* Get files changed since the branch diverged from the target branch.
* Falls back to checking HEAD~20 if merge-base detection fails.
*/
function getChangedFilesSinceBranch(basePath: string, targetBranch: string): string[] {
try {
// Try merge-base approach first
const mergeBase = execFileSync(
"git", ["merge-base", targetBranch, "HEAD"],
{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
if (mergeBase) {
const result = execFileSync(
"git", ["diff", "--name-only", mergeBase, "HEAD"],
{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
return result ? result.split("\n").filter(Boolean) : [];
}
} catch {
// merge-base failed — fall back
}
// Fallback: check last 20 commits
try {
const result = execFileSync(
"git", ["log", "--name-only", "--pretty=format:", "-20", "HEAD"],
{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
return result ? [...new Set(result.split("\n").filter(Boolean))] : [];
} catch {
return [];
}
}
/**
* Check whether the expected artifact(s) for a unit exist on disk.
* Returns true if all required artifacts exist, or if the unit type has no
@ -208,10 +319,15 @@ export function verifyExpectedArtifact(
// plan has no tasks, creating an infinite skip loop (#699).
if (unitType === "plan-slice") {
const planContent = readFileSync(absPath, "utf-8");
if (!/^- \[[xX ]\] \*\*T\d+:/m.test(planContent)) return false;
// Accept checkbox-style (- [x] **T01: ...) or heading-style (### T01 -- / ### T01: / ### T01 —)
const hasCheckboxTask = /^- \[[xX ]\] \*\*T\d+:/m.test(planContent);
const hasHeadingTask = /^#{2,4}\s+T\d+\s*(?:--|—|:)/m.test(planContent);
if (!hasCheckboxTask && !hasHeadingTask) return false;
}
// execute-task must also have its checkbox marked [x] in the slice plan
// execute-task must also have its checkbox marked [x] in the slice plan.
// Heading-style plans (### T01 -- Title) have no checkbox — the task summary
// file existence (checked above via resolveExpectedArtifactPath) is sufficient.
if (unitType === "execute-task") {
const parts = unitId.split("/");
const mid = parts[0];
@ -222,8 +338,11 @@ export function verifyExpectedArtifact(
if (planAbs && existsSync(planAbs)) {
const planContent = readFileSync(planAbs, "utf-8");
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m");
if (!re.test(planContent)) return false;
const cbRe = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m");
const hdRe = new RegExp(`^#{2,4}\\s+${escapedTid}\\s*(?:--|—|:)`, "m");
// Heading-style entries count as verified (no checkbox to toggle);
// checkbox-style entries require [x].
if (!cbRe.test(planContent) && !hdRe.test(planContent)) return false;
}
}
}
@ -287,6 +406,13 @@ export function verifyExpectedArtifact(
}
}
// complete-milestone must have produced implementation artifacts (#1703).
// A milestone with only .gsd/ plan files and zero implementation code is
// not genuinely complete — the LLM wrote plan files but skipped actual work.
if (unitType === "complete-milestone") {
if (!hasImplementationArtifacts(base)) return false;
}
return true;
}
@ -327,6 +453,8 @@ export function diagnoseExpectedArtifact(
const mid = parts[0];
const sid = parts[1];
switch (unitType) {
case "discuss-milestone":
return `${relMilestoneFile(base, mid!, "CONTEXT")} (milestone context from discussion)`;
case "research-milestone":
return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
case "plan-milestone":

View file

@ -21,7 +21,7 @@ import {
resolveSkillDiscoveryMode,
getIsolationMode,
} from "./preferences.js";
import { ensureGsdSymlink, validateProjectId } from "./repo-identity.js";
import { ensureGsdSymlink, isInheritedRepo, validateProjectId } from "./repo-identity.js";
import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
import { gsdRoot, resolveMilestoneFile } from "./paths.js";
@ -135,8 +135,13 @@ export async function bootstrapAutoSession(
return releaseLockAndReturn();
}
// Ensure git repo exists
if (!nativeIsRepo(base)) {
// Ensure git repo exists.
// Guard against inherited repos: if `base` is a subdirectory of another
// git repo that has no .gsd (i.e. the parent project was never initialised
// with GSD), create a fresh git repo at `base` so it gets its own identity
// hash. Without this, repoIdentity() resolves to the parent repo's hash
// and loads milestones from an unrelated project (#1639).
if (!nativeIsRepo(base) || isInheritedRepo(base)) {
const mainBranch =
loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
nativeInit(base, mainBranch);
@ -254,7 +259,7 @@ export async function bootstrapAutoSession(
let hasSurvivorBranch = false;
if (
state.activeMilestone &&
(state.phase === "pre-planning" || state.phase === "needs-discussion") &&
state.phase === "pre-planning" &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base) &&
!base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`)
@ -270,6 +275,32 @@ export async function bootstrapAutoSession(
}
}
// Survivor branch exists but milestone still needs discussion (#1726):
// The worktree/branch was created but the milestone only has CONTEXT-DRAFT.md.
// Route to the interactive discussion handler instead of falling through to
// auto-mode, which would immediately stop with "needs discussion".
if (hasSurvivorBranch && state.phase === "needs-discussion") {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches();
const postState = await deriveState(base);
if (
postState.activeMilestone &&
postState.phase !== "needs-discussion"
) {
state = postState;
// Discussion succeeded — clear survivor flag so normal flow continues
hasSurvivorBranch = false;
} else {
ctx.ui.notify(
"Discussion completed but milestone draft was not promoted. Run /gsd to try again.",
"warning",
);
return releaseLockAndReturn();
}
}
if (!hasSurvivorBranch) {
// No active work — start a new milestone via discuss flow
if (!state.activeMilestone || state.phase === "complete") {
@ -345,6 +376,27 @@ export async function bootstrapAutoSession(
}
}
}
// Active milestone has CONTEXT-DRAFT but no full context — needs discussion
if (state.phase === "needs-discussion") {
const { showSmartEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches();
const postState = await deriveState(base);
if (
postState.activeMilestone &&
postState.phase !== "needs-discussion"
) {
state = postState;
} else {
ctx.ui.notify(
"Discussion completed but milestone draft was not promoted. Run /gsd to try again.",
"warning",
);
return releaseLockAndReturn();
}
}
}
// Unreachable safety check

View file

@ -1,16 +1,24 @@
/**
* Auto-mode Supervisor SIGTERM handling and working-tree activity detection.
* Auto-mode Supervisor signal handling and working-tree activity detection.
*
* Pure functions no module-level globals or AutoContext dependency.
*/
import { clearLock } from "./crash-recovery.js";
import { releaseSessionLock } from "./session-lock.js";
import { nativeHasChanges } from "./native-git-bridge.js";
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
// ─── Signal Handling ─────────────────────────────────────────────────────────
/** Signals that should trigger lock cleanup on process termination. */
const CLEANUP_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGHUP", "SIGINT"];
/**
* Register a SIGTERM handler that clears the lock file and exits cleanly.
* Register signal handlers that clear lock files and exit cleanly.
* Installs handlers on SIGTERM, SIGHUP, and SIGINT so that lock files
* are cleaned up regardless of how the process is terminated (normal kill,
* parent process death, or Ctrl+C).
*
* Captures the active base path at registration time so the handler
* always references the correct path even if the module variable changes.
* Removes any previously registered handler before installing the new one.
@ -21,19 +29,22 @@ export function registerSigtermHandler(
currentBasePath: string,
previousHandler: (() => void) | null,
): () => void {
if (previousHandler) process.off("SIGTERM", previousHandler);
if (previousHandler) {
for (const sig of CLEANUP_SIGNALS) process.off(sig, previousHandler);
}
const handler = () => {
clearLock(currentBasePath);
releaseSessionLock(currentBasePath);
process.exit(0);
};
process.on("SIGTERM", handler);
for (const sig of CLEANUP_SIGNALS) process.on(sig, handler);
return handler;
}
/** Deregister the SIGTERM handler (called on stop/pause). */
/** Deregister signal handlers from all cleanup signals (called on stop/pause). */
export function deregisterSigtermHandler(handler: (() => void) | null): void {
if (handler) {
process.off("SIGTERM", handler);
for (const sig of CLEANUP_SIGNALS) process.off(sig, handler);
}
}

View file

@ -19,6 +19,7 @@ import { detectWorkingTreeActivity } from "./auto-supervisor.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
import { saveActivityLog } from "./activity-log.js";
import { recoverTimedOutUnit, type RecoveryContext } from "./auto-timeout-recovery.js";
import { resolveAgentEndCancelled } from "./auto/resolve.js";
import type { AutoSession } from "./auto/session.js";
export interface SupervisionContext {
@ -129,6 +130,8 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[idle-watchdog] Unhandled error: ${message}`);
// Unblock any pending unit promise so the auto-loop is not orphaned.
resolveAgentEndCancelled();
try {
ctx.ui.notify(`Idle watchdog error: ${message}`, "warning");
} catch { /* best effort */ }
@ -161,6 +164,8 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[hard-timeout] Unhandled error: ${message}`);
// Unblock any pending unit promise so the auto-loop is not orphaned.
resolveAgentEndCancelled();
try {
ctx.ui.notify(`Hard timeout error: ${message}`, "warning");
} catch { /* best effort */ }

View file

@ -27,7 +27,10 @@ export function markToolEnd(toolCallId: string): void {
*/
export function getOldestInFlightToolAgeMs(): number {
if (inFlightTools.size === 0) return 0;
const oldestStart = Math.min(...inFlightTools.values());
let oldestStart = Infinity;
for (const t of inFlightTools.values()) {
if (t < oldestStart) oldestStart = t;
}
return Date.now() - oldestStart;
}
@ -43,7 +46,11 @@ export function getInFlightToolCount(): number {
*/
export function getOldestInFlightToolStart(): number | undefined {
if (inFlightTools.size === 0) return undefined;
return Math.min(...inFlightTools.values());
let oldest = Infinity;
for (const t of inFlightTools.values()) {
if (t < oldest) oldest = t;
}
return oldest;
}
/**

View file

@ -170,6 +170,20 @@ export function escapeStaleWorktree(base: string): string {
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
// Guard: If the candidate project root's .gsd IS the user-level ~/.gsd,
// the string-slice heuristic matched the wrong /.gsd/ boundary. This happens
// when .gsd is a symlink into ~/.gsd/projects/<hash> and process.cwd()
// resolved through the symlink. Returning ~ would be catastrophic (#1676).
const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/");
const gsdHomePath = gsdHome.replaceAll("\\", "/");
if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) {
// Don't chdir to home — return base unchanged.
// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
// and will be called by the caller (startAuto → projectRoot()).
return base;
}
try {
process.chdir(projectRoot);
} catch {

View file

@ -13,6 +13,7 @@ import {
readdirSync,
mkdirSync,
realpathSync,
rmSync,
unlinkSync,
lstatSync as lstatSyncFn,
} from "node:fs";
@ -24,12 +25,13 @@ import {
isDbAvailable,
} from "./gsd-db.js";
import { atomicWriteSync } from "./atomic-write.js";
import { execSync, execFileSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import { gsdRoot } from "./paths.js";
import {
createWorktree,
removeWorktree,
resolveGitDir,
worktreePath,
} from "./worktree-manager.js";
import {
@ -38,10 +40,12 @@ import {
nudgeGitBranchCache,
} from "./worktree.js";
import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
import { debugLog } from "./debug-logger.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import {
nativeGetCurrentBranch,
nativeDetectMainBranch,
nativeWorkingTreeStatus,
nativeAddAllWithExclusions,
nativeCommit,
@ -53,6 +57,9 @@ import {
nativeRmForce,
nativeBranchDelete,
nativeBranchExists,
nativeDiffNumstat,
nativeUpdateRef,
nativeIsAncestor,
} from "./native-git-bridge.js";
// ─── Module State ──────────────────────────────────────────────────────────
@ -75,6 +82,41 @@ function clearProjectRootStateFiles(basePath: string, milestoneId: string): void
/* non-fatal — file may not exist */
}
}
// Clean up entire synced milestone directory and runtime/units.
// syncStateToProjectRoot() copies these into the project root during
// execution. If they remain as untracked files when we attempt
// `git merge --squash`, git rejects the merge with "local changes would
// be overwritten", causing silent data loss (#1738).
const syncedDirs = [
join(gsdDir, "milestones", milestoneId),
join(gsdDir, "runtime", "units"),
];
for (const dir of syncedDirs) {
try {
if (existsSync(dir)) {
// Only remove files that are untracked by git — tracked files are
// managed by the branch checkout and should not be deleted.
const untrackedOutput = execFileSync(
"git",
["ls-files", "--others", "--exclude-standard", dir],
{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
).trim();
if (untrackedOutput) {
for (const f of untrackedOutput.split("\n").filter(Boolean)) {
try {
unlinkSync(join(basePath, f));
} catch {
/* non-fatal */
}
}
}
}
} catch {
/* non-fatal — git command may fail if not in repo */
}
}
}
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
@ -111,13 +153,15 @@ export function syncGsdStateToWorktree(
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.)
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
];
for (const f of rootFiles) {
const src = join(mainGsd, f);
@ -141,7 +185,7 @@ export function syncGsdStateToWorktree(
const mainMilestones = readdirSync(mainMilestonesDir, {
withFileTypes: true,
})
.filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name))
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const mid of mainMilestones) {
@ -227,8 +271,20 @@ export function syncGsdStateToWorktree(
* Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION,
* updated ROADMAP) are visible from the project root (#1412).
*
* Only syncs .gsd/milestones/ content root-level files (DECISIONS, REQUIREMENTS, etc.)
* are handled by the merge itself.
* Syncs:
* 1. Root-level .gsd/ files (REQUIREMENTS, PROJECT, DECISIONS, KNOWLEDGE,
* OVERRIDES) the worktree's versions overwrite main's because the
* worktree is the authoritative execution context.
* 2. ALL milestone directories found in the worktree not just the
* current milestoneId. The complete-milestone unit may create artifacts
* for the *next* milestone (CONTEXT, ROADMAP, new requirements) which
* must survive worktree teardown.
*
* History: Originally only synced milestones/<milestoneId>/ and assumed
* root-level files would be carried by the squash merge. In practice,
* .gsd/ files are often untracked (gitignored or never committed), so the
* squash merge carries nothing. This caused next-milestone artifacts and
* updated REQUIREMENTS/PROJECT to be silently lost on teardown.
*/
export function syncWorktreeStateBack(
mainBasePath: string,
@ -248,10 +304,71 @@ export function syncWorktreeStateBack(
// Can't resolve — proceed with sync
}
const wtMilestoneDir = join(wtGsd, "milestones", milestoneId);
const mainMilestoneDir = join(mainGsd, "milestones", milestoneId);
if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced };
if (!existsSync(wtMilestoneDir)) return { synced };
// ── 1. Sync root-level .gsd/ files back ──────────────────────────────
// The worktree is authoritative — complete-milestone updates REQUIREMENTS,
// PROJECT, etc. These must overwrite main's copies so they survive teardown.
// Also includes QUEUE.md and completed-units.json which are written during
// milestone closeout and lost on teardown without explicit sync (#1787).
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
];
for (const f of rootFiles) {
const src = join(wtGsd, f);
const dst = join(mainGsd, f);
if (existsSync(src)) {
try {
cpSync(src, dst, { force: true });
synced.push(f);
} catch {
/* non-fatal */
}
}
}
// ── 2. Sync ALL milestone directories ────────────────────────────────
// The complete-milestone unit may create next-milestone artifacts (e.g.
// M007 setup while closing M006). We must sync every milestone directory
// in the worktree, not just the current one.
const wtMilestonesDir = join(wtGsd, "milestones");
if (!existsSync(wtMilestonesDir)) return { synced };
try {
const wtMilestones = readdirSync(wtMilestonesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const mid of wtMilestones) {
syncMilestoneDir(wtGsd, mainGsd, mid, synced);
}
} catch {
/* non-fatal */
}
return { synced };
}
/**
* Sync a single milestone directory from worktree to main.
* Copies milestone-level .md files, slice-level files, and task summaries.
*/
function syncMilestoneDir(
wtGsd: string,
mainGsd: string,
mid: string,
synced: string[],
): void {
const wtMilestoneDir = join(wtGsd, "milestones", mid);
const mainMilestoneDir = join(mainGsd, "milestones", mid);
if (!existsSync(wtMilestoneDir)) return;
mkdirSync(mainMilestoneDir, { recursive: true });
// Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT)
@ -262,7 +379,7 @@ export function syncWorktreeStateBack(
const dst = join(mainMilestoneDir, entry.name);
try {
cpSync(src, dst, { force: true });
synced.push(`milestones/${milestoneId}/${entry.name}`);
synced.push(`milestones/${mid}/${entry.name}`);
} catch {
/* non-fatal */
}
@ -295,11 +412,36 @@ export function syncWorktreeStateBack(
try {
cpSync(src, dst, { force: true });
synced.push(
`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`,
`milestones/${mid}/slices/${sid}/${fileEntry.name}`,
);
} catch {
/* non-fatal */
}
} else if (fileEntry.isDirectory() && fileEntry.name === "tasks") {
// Recurse into tasks/ subdirectory to sync task summaries (#1678).
// Without this, T01-SUMMARY.md etc. are silently dropped on
// worktree teardown because the loop only processes isFile() entries.
const wtTasksDir = join(wtSliceDir, "tasks");
const mainTasksDir = join(mainSliceDir, "tasks");
mkdirSync(mainTasksDir, { recursive: true });
try {
for (const taskEntry of readdirSync(wtTasksDir, { withFileTypes: true })) {
if (taskEntry.isFile() && taskEntry.name.endsWith(".md")) {
const taskSrc = join(wtTasksDir, taskEntry.name);
const taskDst = join(mainTasksDir, taskEntry.name);
try {
cpSync(taskSrc, taskDst, { force: true });
synced.push(
`milestones/${mid}/slices/${sid}/tasks/${taskEntry.name}`,
);
} catch {
/* non-fatal */
}
}
}
} catch {
/* non-fatal: tasks dir read failure */
}
}
}
}
@ -307,8 +449,6 @@ export function syncWorktreeStateBack(
/* non-fatal */
}
}
return { synced };
}
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
@ -338,7 +478,7 @@ export function runWorktreePostCreateHook(
}
try {
execSync(resolved, {
execFileSync(resolved, [], {
cwd: worktreeDir,
env: {
...process.env,
@ -624,6 +764,24 @@ export function teardownAutoWorktree(
branch,
deleteBranch: !preserveBranch,
});
// Verify cleanup succeeded — warn if the worktree directory is still on disk.
// On Windows, bash-based cleanup can silently fail when paths contain
// backslashes (#1436), leaving ~1 GB+ orphaned directories.
const wtDir = worktreePath(originalBasePath, milestoneId);
if (existsSync(wtDir)) {
console.error(
`[GSD] WARNING: Worktree directory still exists after teardown: ${wtDir}\n` +
` This is likely an orphaned directory consuming disk space.\n` +
` Remove it manually with: rm -rf "${wtDir.replaceAll("\\", "/")}"`,
);
// Attempt a direct filesystem removal as a fallback
try {
rmSync(wtDir, { recursive: true, force: true });
} catch {
// Non-fatal — the warning above tells the user how to clean up
}
}
}
/**
@ -774,7 +932,8 @@ function autoCommitDirtyState(cwd: string): boolean {
"chore: auto-commit before milestone merge",
);
return result !== null;
} catch {
} catch (e) {
debugLog("autoCommitDirtyState", { error: String(e) });
return false;
}
}
@ -795,7 +954,8 @@ function autoCommitDirtyState(cwd: string): boolean {
* 9. Clear originalBase
*
* On merge conflict: throws MergeConflictError.
* On "nothing to commit" after squash: handles gracefully (no error).
* On "nothing to commit" after squash: safe only if milestone work is already
* on the integration branch. Throws if unanchored code changes would be lost.
*/
export function mergeMilestoneToMain(
originalBasePath_: string,
@ -827,13 +987,17 @@ export function mergeMilestoneToMain(
const previousCwd = process.cwd();
process.chdir(originalBasePath_);
// 4. Resolve integration branch — prefer milestone metadata, fall back to preferences / "main"
// 4. Resolve integration branch — prefer milestone metadata, then preferences,
// then auto-detect (origin/HEAD → main → master → current). Never hardcode
// "main": repos using "master" or a custom default branch would fail at
// checkout and leave the user with a broken merge state (#1668).
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const integrationBranch = readIntegrationBranch(
originalBasePath_,
milestoneId,
);
const mainBranch = integrationBranch ?? prefs.main_branch ?? "main";
const mainBranch =
integrationBranch ?? prefs.main_branch ?? nativeDetectMainBranch(originalBasePath_);
// Remove transient project-root state files before any branch or merge
// operation. Untracked milestone metadata can otherwise block squash merges.
@ -859,10 +1023,79 @@ export function mergeMilestoneToMain(
}
const commitMessage = subject + body;
// 6b. Reconcile worktree HEAD with milestone branch ref (#1846).
// When the worktree HEAD detaches and advances past the named branch,
// the branch ref becomes stale. Squash-merging the stale ref silently
// orphans all commits between the branch ref and the actual worktree HEAD.
// Fix: fast-forward the branch ref to the worktree HEAD before merging.
// Only applies when merging from an actual worktree (worktreeCwd differs
// from originalBasePath_).
if (worktreeCwd !== originalBasePath_) {
try {
const worktreeHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: worktreeCwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
const branchHead = execFileSync("git", ["rev-parse", milestoneBranch], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
if (worktreeHead && branchHead && worktreeHead !== branchHead) {
if (nativeIsAncestor(originalBasePath_, branchHead, worktreeHead)) {
// Worktree HEAD is strictly ahead — fast-forward the branch ref
nativeUpdateRef(
originalBasePath_,
`refs/heads/${milestoneBranch}`,
worktreeHead,
);
debugLog("mergeMilestoneToMain", {
action: "fast-forward-branch-ref",
milestoneBranch,
oldRef: branchHead.slice(0, 8),
newRef: worktreeHead.slice(0, 8),
});
} else {
// Diverged — fail loudly rather than silently losing commits
process.chdir(previousCwd);
throw new GSDError(
GSD_GIT_ERROR,
`Worktree HEAD (${worktreeHead.slice(0, 8)}) diverged from ` +
`${milestoneBranch} (${branchHead.slice(0, 8)}). ` +
`Manual reconciliation required before merge.`,
);
}
}
} catch (err) {
// Re-throw GSDError (divergence); swallow rev-parse failures
// (e.g. worktree dir already removed by external cleanup)
if (err instanceof GSDError) throw err;
debugLog("mergeMilestoneToMain", {
action: "reconcile-skipped",
reason: String(err),
});
}
}
// 7. Squash merge — auto-resolve .gsd/ state file conflicts (#530)
const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);
if (!mergeResult.success) {
// Dirty working tree — the merge was rejected before it started (e.g.
// untracked .gsd/ files left by syncStateToProjectRoot). Preserve the
// milestone branch so commits are not lost.
if (mergeResult.conflicts.includes("__dirty_working_tree__")) {
// Restore cwd so the caller is not stranded on the integration branch
process.chdir(previousCwd);
throw new GSDError(
GSD_GIT_ERROR,
`Squash merge of ${milestoneBranch} rejected: working tree has dirty or untracked files that conflict with the merge. ` +
`Clean the project root .gsd/ directory and retry.`,
);
}
// Check for conflicts — use merge result first, fall back to nativeConflictFiles
const conflictedFiles =
mergeResult.conflicts.length > 0
@ -910,12 +1143,47 @@ export function mergeMilestoneToMain(
const commitResult = nativeCommit(originalBasePath_, commitMessage);
const nothingToCommit = commitResult === null;
// 8a. Clean up SQUASH_MSG left by git merge --squash (#1853).
// git only removes SQUASH_MSG when the commit reads it directly (plain
// `git commit`). nativeCommit uses `-F -` (stdin) or libgit2, neither
// of which trigger git's SQUASH_MSG cleanup. If left on disk, doctor
// reports `corrupt_merge_state` on every subsequent run.
try {
const squashMsgPath = join(resolveGitDir(originalBasePath_), "SQUASH_MSG");
if (existsSync(squashMsgPath)) unlinkSync(squashMsgPath);
} catch { /* best-effort */ }
// 8b. Safety check (#1792): if nothing was committed, verify the milestone
// work is already on the integration branch before allowing teardown.
// Compare only non-.gsd/ paths — .gsd/ state files diverge normally and
// are auto-resolved during the squash merge.
if (nothingToCommit) {
const numstat = nativeDiffNumstat(
originalBasePath_,
mainBranch,
milestoneBranch,
);
const codeChanges = numstat.filter(
(entry) => !entry.path.startsWith(".gsd/"),
);
if (codeChanges.length > 0) {
// Milestone has unanchored code changes — abort teardown.
process.chdir(previousCwd);
throw new GSDError(
GSD_GIT_ERROR,
`Squash merge produced nothing to commit but milestone branch "${milestoneBranch}" ` +
`has ${codeChanges.length} code file(s) not on "${mainBranch}". ` +
`Aborting worktree teardown to prevent data loss.`,
);
}
}
// 9. Auto-push if enabled
let pushed = false;
if (prefs.auto_push === true && !nothingToCommit) {
const remote = prefs.remote ?? "origin";
try {
execSync(`git push ${remote} ${mainBranch}`, {
execFileSync("git", ["push", remote, mainBranch], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
@ -933,27 +1201,58 @@ export function mergeMilestoneToMain(
const prTarget = prefs.pr_target_branch ?? mainBranch;
try {
// Push the milestone branch to remote first
execSync(`git push ${remote} ${milestoneBranch}`, {
execFileSync("git", ["push", remote, milestoneBranch], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
// Create PR via gh CLI
execSync(
`gh pr create --base "${prTarget}" --head "${milestoneBranch}" --title "Milestone ${milestoneId} complete" --body "Auto-created by GSD on milestone completion."`,
{
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
},
);
execFileSync("gh", [
"pr", "create",
"--base", prTarget,
"--head", milestoneBranch,
"--title", `Milestone ${milestoneId} complete`,
"--body", "Auto-created by GSD on milestone completion.",
], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
prCreated = true;
} catch {
// PR creation failure is non-fatal — gh may not be installed or authenticated
}
}
// 10. Remove worktree directory first (must happen before branch deletion)
// 10. Guard removed — step 8b (#1792) now handles this with a smarter check:
// throws only when the milestone has unanchored code changes, passes
// through when the code is genuinely already on the integration branch.
// 10a. Pre-teardown safety net (#1853): if the worktree still has uncommitted
// changes (e.g. nativeHasChanges cache returned stale false, or auto-commit
// silently failed), force one final commit so code is not destroyed by
// `git worktree remove --force`.
if (existsSync(worktreeCwd)) {
try {
const dirtyCheck = nativeWorkingTreeStatus(worktreeCwd);
if (dirtyCheck) {
debugLog("mergeMilestoneToMain", {
phase: "pre-teardown-dirty",
worktreeCwd,
status: dirtyCheck.slice(0, 200),
});
nativeAddAllWithExclusions(worktreeCwd, RUNTIME_EXCLUSION_PATHS);
nativeCommit(worktreeCwd, "chore: pre-teardown auto-commit of uncommitted worktree changes");
}
} catch (e) {
debugLog("mergeMilestoneToMain", {
phase: "pre-teardown-commit-error",
error: String(e),
});
}
}
// 11. Remove worktree directory first (must happen before branch deletion)
try {
removeWorktree(originalBasePath_, milestoneId, {
branch: null as unknown as string,
@ -963,14 +1262,14 @@ export function mergeMilestoneToMain(
// Best-effort -- worktree dir may already be gone
}
// 11. Delete milestone branch (after worktree removal so ref is unlocked)
// 12. Delete milestone branch (after worktree removal so ref is unlocked)
try {
nativeBranchDelete(originalBasePath_, milestoneBranch);
} catch {
// Best-effort
}
// 12. Clear module state
// 13. Clear module state
originalBase = null;
nudgeGitBranchCache(previousCwd);

View file

@ -91,7 +91,8 @@ import {
} from "./auto-observability.js";
import { closeoutUnit } from "./auto-unit-closeout.js";
import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
import { selectAndApplyModel } from "./auto-model-selection.js";
import { selfHealRuntimeRecords } from "./auto-recovery.js";
import { selectAndApplyModel, resolveModelId } from "./auto-model-selection.js";
import {
syncProjectRootToWorktree,
syncStateToProjectRoot,
@ -172,7 +173,9 @@ import {
buildLoopRemediationSteps,
reconcileMergeState,
} from "./auto-recovery.js";
import { resolveDispatch } from "./auto-dispatch.js";
import { resolveDispatch, DISPATCH_RULES } from "./auto-dispatch.js";
import { initRegistry, convertDispatchRules } from "./rule-registry.js";
import { emitJournalEvent as _emitJournalEvent, type JournalEntry } from "./journal.js";
import {
type AutoDashboardData,
updateProgressWidget as _updateProgressWidget,
@ -202,7 +205,7 @@ import {
postUnitPostVerification,
} from "./auto-post-unit.js";
import { bootstrapAutoSession, type BootstrapDeps } from "./auto-start.js";
import { autoLoop, resolveAgentEnd, isSessionSwitchInFlight, type LoopDeps } from "./auto-loop.js";
import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps } from "./auto-loop.js";
import {
WorktreeResolver,
type WorktreeResolverDeps,
@ -339,7 +342,9 @@ export function getAutoDashboardData(): AutoDashboardData {
paused: s.paused,
stepMode: s.stepMode,
startTime: s.autoStartTime,
elapsed: s.active || s.paused ? Date.now() - s.autoStartTime : 0,
elapsed: s.active || s.paused
? (s.autoStartTime > 0 ? Date.now() - s.autoStartTime : 0)
: 0,
currentUnit: s.currentUnit ? { ...s.currentUnit } : null,
completedUnits: [...s.completedUnits],
basePath: s.basePath,
@ -516,16 +521,19 @@ function handleLostSessionLock(
clearUnitTimeout();
deregisterSigtermHandler();
clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences);
const base = lockBase();
const lockFilePath = base ? join(gsdRoot(base), "auto.lock") : "unknown";
const recoverySuggestion = "\nTo recover, run: gsd doctor --fix";
const message =
lockStatus?.failureReason === "pid-mismatch"
? lockStatus.existingPid
? `Session lock moved to PID ${lockStatus.existingPid} — another GSD process appears to have taken over. Stopping gracefully.`
: "Session lock moved to a different process — another GSD process appears to have taken over. Stopping gracefully."
? `Session lock (${lockFilePath}) moved to PID ${lockStatus.existingPid} — another GSD process appears to have taken over. Stopping gracefully.${recoverySuggestion}`
: `Session lock (${lockFilePath}) moved to a different process — another GSD process appears to have taken over. Stopping gracefully.${recoverySuggestion}`
: lockStatus?.failureReason === "missing-metadata"
? "Session lock metadata disappeared, so ownership could not be confirmed. Stopping gracefully."
? `Session lock metadata (${lockFilePath}) disappeared, so ownership could not be confirmed. Stopping gracefully.${recoverySuggestion}`
: lockStatus?.failureReason === "compromised"
? "Session lock was compromised or invalidated during heartbeat checks; takeover was not confirmed. Stopping gracefully."
: "Session lock lost. Stopping gracefully.";
? `Session lock (${lockFilePath}) was compromised during heartbeat checks (PID ${process.pid}). This can happen after long event loop stalls during subagent execution.${recoverySuggestion}`
: `Session lock lost (${lockFilePath}). Stopping gracefully.${recoverySuggestion}`;
ctx?.ui.notify(
message,
"error",
@ -535,6 +543,33 @@ function handleLostSessionLock(
ctx?.ui.setFooter(undefined);
}
/**
* Lightweight cleanup after autoLoop exits via step-wizard break.
*
* Unlike stopAuto (which tears down the entire session), this only clears
* the stale unit state, progress widget, status badge, and restores CWD so
* the dashboard does not show an orphaned timer and the shell is usable.
*/
function cleanupAfterLoopExit(ctx: ExtensionContext): void {
s.currentUnit = null;
s.active = false;
clearUnitTimeout();
ctx.ui.setStatus("gsd-auto", undefined);
ctx.ui.setWidget("gsd-progress", undefined);
ctx.ui.setFooter(undefined);
// Restore CWD out of worktree back to original project root
if (s.originalBasePath) {
s.basePath = s.originalBasePath;
try {
process.chdir(s.basePath);
} catch {
/* best-effort */
}
}
}
export async function stopAuto(
ctx?: ExtensionContext,
pi?: ExtensionAPI,
@ -688,8 +723,28 @@ export async function stopAuto(
} catch (e) {
debugLog("stop-cleanup-model", { error: e instanceof Error ? e.message : String(e) });
}
// ── Step 14: Unblock pending unitPromise (#1799) ──
// resolveAgentEnd unblocks autoLoop's `await unitPromise` so it can see
// s.active === false and exit cleanly. Without this, autoLoop hangs
// forever and the interactive loop is blocked.
try {
resolveAgentEnd({ messages: [] });
_resetPendingResolve();
} catch (e) {
debugLog("stop-cleanup-pending-resolve", { error: e instanceof Error ? e.message : String(e) });
}
} finally {
// ── Critical invariants: these MUST execute regardless of errors ──
// Browser teardown — prevent orphaned Chrome processes across retries (#1733)
try {
const { getBrowser } = await import("../browser-tools/state.js");
if (getBrowser()) {
const { closeBrowser } = await import("../browser-tools/lifecycle.js");
await closeBrowser();
}
} catch { /* non-fatal: browser-tools may not be loaded */ }
// External cleanup (not covered by session reset)
clearInFlightTools();
clearSliceProgressCache();
@ -718,6 +773,8 @@ export async function pauseAuto(
): Promise<void> {
if (!s.active) return;
clearUnitTimeout();
// Unblock any pending unit promise so the auto-loop is not orphaned.
resolveAgentEndCancelled();
s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
@ -745,6 +802,21 @@ export async function pauseAuto(
// Non-fatal — resume will still work via full bootstrap, just without worktree context
}
// Close out the current unit so its runtime record doesn't stay at "dispatched"
if (s.currentUnit && ctx) {
try {
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
} catch {
// Non-fatal — best-effort closeout on pause
}
try {
clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
} catch {
// Non-fatal
}
s.currentUnit = null;
}
if (lockBase()) {
releaseSessionLock(lockBase());
clearLock(lockBase());
@ -752,6 +824,10 @@ export async function pauseAuto(
deregisterSigtermHandler();
// Unblock pending unitPromise so autoLoop exits cleanly (#1799)
resolveAgentEnd({ messages: [] });
_resetPendingResolve();
s.active = false;
s.paused = true;
s.pendingVerificationRetry = null;
@ -810,6 +886,11 @@ function buildResolver(): WorktreeResolver {
* This bundles all private functions that autoLoop needs without exporting them.
*/
function buildLoopDeps(): LoopDeps {
// Initialize the unified rule registry with converted dispatch rules.
// Must happen before LoopDeps is assembled so facade functions
// (resolveDispatch, runPreDispatchHooks, etc.) delegate to the registry.
initRegistry(convertDispatchRules(DISPATCH_RULES));
return {
lockBase,
buildSnapshotOpts,
@ -823,6 +904,7 @@ function buildLoopDeps(): LoopDeps {
// State and cache
invalidateAllCaches,
deriveState,
rebuildState,
loadEffectiveGSDPreferences,
// Pre-dispatch health gate
@ -886,6 +968,7 @@ function buildLoopDeps(): LoopDeps {
// Model selection + supervision
selectAndApplyModel,
resolveModelId,
startUnitSupervision,
// Prompt helpers
@ -918,6 +1001,9 @@ function buildLoopDeps(): LoopDeps {
return "";
}
},
// Journal
emitJournalEvent: (entry: JournalEntry) => _emitJournalEvent(s.basePath, entry),
} as unknown as LoopDeps;
}
@ -966,18 +1052,30 @@ export async function startAuto(
|| !!freshStartAssessment.lock
);
if (shouldResumePausedSession) {
s.currentMilestoneId = meta.milestoneId;
s.originalBasePath = meta.originalBasePath || base;
s.stepMode = meta.stepMode ?? requestedStepMode;
s.pausedSessionFile = meta.sessionFile ?? null;
s.pausedUnitType = meta.unitType ?? null;
s.pausedUnitId = meta.unitId ?? null;
s.paused = true;
try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
ctx.ui.notify(
`Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`,
"info",
);
// Validate the milestone still exists and isn't already complete (#1664).
const mDir = resolveMilestonePath(base, meta.milestoneId);
const summaryFile = resolveMilestoneFile(base, meta.milestoneId, "SUMMARY");
if (!mDir || summaryFile) {
// Stale milestone — clean up and fall through to fresh bootstrap
try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
ctx.ui.notify(
`Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`,
"info",
);
} else {
s.currentMilestoneId = meta.milestoneId;
s.originalBasePath = meta.originalBasePath || base;
s.stepMode = meta.stepMode ?? requestedStepMode;
s.pausedSessionFile = meta.sessionFile ?? null;
s.pausedUnitType = meta.unitType ?? null;
s.pausedUnitId = meta.unitId ?? null;
s.paused = true;
try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
ctx.ui.notify(
`Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`,
"info",
);
}
} else if (existsSync(pausedPath)) {
try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
}
@ -1091,6 +1189,15 @@ export async function startAuto(
}
invalidateAllCaches();
// Clean stale runtime records left from the paused session
try {
await selfHealRuntimeRecords(s.basePath, ctx);
} catch (e) {
debugLog("resume-self-heal-runtime-failed", {
error: e instanceof Error ? e.message : String(e),
});
}
if (s.pausedSessionFile) {
const activityDir = join(gsdRoot(s.basePath), "activity");
const recovery = synthesizeCrashRecovery(
@ -1124,7 +1231,11 @@ export async function startAuto(
);
logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress");
// Clear orphaned runtime records from prior process deaths before entering the loop
await selfHealRuntimeRecords(s.basePath, ctx);
await autoLoop(ctx, pi, s, buildLoopDeps());
cleanupAfterLoopExit(ctx);
return;
}
@ -1155,8 +1266,12 @@ export async function startAuto(
}
logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress");
// Clear orphaned runtime records from prior process deaths before entering the loop
await selfHealRuntimeRecords(s.basePath, ctx);
// Dispatch the first unit
await autoLoop(ctx, pi, s, buildLoopDeps());
cleanupAfterLoopExit(ctx);
}
// ─── Agent End Handler ────────────────────────────────────────────────────────
@ -1174,7 +1289,11 @@ export async function handleAgentEnd(
ctx: ExtensionContext,
pi: ExtensionAPI,
): Promise<void> {
if (!s.active || !s.cmdCtx) return;
if (!s.active || !s.cmdCtx) {
// Even when inactive, resolve any pending promise so the loop is unblocked.
resolveAgentEndCancelled();
return;
}
clearUnitTimeout();
resolveAgentEnd({ messages: [] });
}
@ -1311,15 +1430,19 @@ export async function dispatchHookUnit(
if (hookModel) {
const availableModels = ctx.modelRegistry.getAvailable();
const match = availableModels.find(
(m) => m.id === hookModel || `${m.provider}/${m.id}` === hookModel,
);
const match = resolveModelId(hookModel, availableModels, ctx.model?.provider);
if (match) {
try {
await pi.setModel(match);
} catch {
/* non-fatal */
}
} else {
ctx.ui.notify(
`Hook model "${hookModel}" not found in available models. Falling back to current session model. ` +
`Ensure the model is defined in models.json and has auth configured.`,
"warning",
);
}
}

View file

@ -0,0 +1,60 @@
/**
* auto/detect-stuck.ts Sliding-window stuck detection for the auto-loop.
*
* Leaf node in the import DAG.
*/
import type { WindowEntry } from "./types.js";
/**
* Analyze a sliding window of recent unit dispatches for stuck patterns.
* Returns a signal with reason if stuck, null otherwise.
*
* Rule 1: Same error string twice in a row stuck immediately.
* Rule 2: Same unit key 3+ consecutive times stuck (preserves prior behavior).
* Rule 3: Oscillation ABAB in last 4 entries stuck.
*/
export function detectStuck(
window: readonly WindowEntry[],
): { stuck: true; reason: string } | null {
if (window.length < 2) return null;
const last = window[window.length - 1];
const prev = window[window.length - 2];
// Rule 1: Same error repeated consecutively
if (last.error && prev.error && last.error === prev.error) {
return {
stuck: true,
reason: `Same error repeated: ${last.error.slice(0, 200)}`,
};
}
// Rule 2: Same unit 3+ consecutive times
if (window.length >= 3) {
const lastThree = window.slice(-3);
if (lastThree.every((u) => u.key === last.key)) {
return {
stuck: true,
reason: `${last.key} derived 3 consecutive times without progress`,
};
}
}
// Rule 3: Oscillation (A→B→A→B in last 4)
if (window.length >= 4) {
const w = window.slice(-4);
if (
w[0].key === w[2].key &&
w[1].key === w[3].key &&
w[0].key !== w[1].key
) {
return {
stuck: true,
reason: `Oscillation detected: ${w[0].key}${w[1].key}`,
};
}
}
return null;
}

View file

@ -0,0 +1,41 @@
/**
* auto/infra-errors.ts Infrastructure error detection.
*
* Leaf module with zero transitive dependencies. Used by the auto-loop catch
* block to distinguish unrecoverable OS/filesystem errors from transient
* failures that merit retry.
*/
/**
* Error codes indicating infrastructure failures that cannot be recovered by
* retrying. Each retry re-dispatches the unit at full LLM cost, so we bail
* immediately rather than burning budget on guaranteed failures.
*/
export const INFRA_ERROR_CODES: ReadonlySet<string> = new Set([
"ENOSPC", // disk full
"ENOMEM", // out of memory
"EROFS", // read-only file system
"EDQUOT", // disk quota exceeded
"EMFILE", // too many open files (process)
"ENFILE", // too many open files (system)
]);
/**
* Detect whether an error is an unrecoverable infrastructure failure.
* Checks the `code` property (Node system errors) and falls back to
* scanning the message string for known error code tokens.
*
* Returns the matched code string, or null if the error is not an
* infrastructure failure.
*/
export function isInfrastructureError(err: unknown): string | null {
if (err && typeof err === "object") {
const code = (err as Record<string, unknown>).code;
if (typeof code === "string" && INFRA_ERROR_CODES.has(code)) return code;
}
const msg = err instanceof Error ? err.message : String(err);
for (const code of INFRA_ERROR_CODES) {
if (msg.includes(code)) return code;
}
return null;
}

View file

@ -0,0 +1,292 @@
/**
* auto/loop-deps.ts LoopDeps interface for dependency injection into autoLoop.
*
* Leaf node in the import DAG (type-only).
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import type { GSDPreferences } from "../preferences.js";
import type { GSDState } from "../types.js";
import type { SessionLockStatus } from "../session-lock.js";
import type { CloseoutOptions } from "../auto-unit-closeout.js";
import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js";
import type {
VerificationContext,
VerificationResult,
} from "../auto-verification.js";
import type { DispatchAction } from "../auto-dispatch.js";
import type { WorktreeResolver } from "../worktree-resolver.js";
import type { CmuxLogLevel } from "../../cmux/index.js";
import type { JournalEntry } from "../journal.js";
/**
* Dependencies injected by the caller (auto.ts startAuto) so autoLoop
* can access private functions from auto.ts without exporting them.
*/
export interface LoopDeps {
lockBase: () => string;
buildSnapshotOpts: (
unitType: string,
unitId: string,
) => CloseoutOptions & Record<string, unknown>;
stopAuto: (
ctx?: ExtensionContext,
pi?: ExtensionAPI,
reason?: string,
) => Promise<void>;
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>;
clearUnitTimeout: () => void;
updateProgressWidget: (
ctx: ExtensionContext,
unitType: string,
unitId: string,
state: GSDState,
) => void;
syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void;
logCmuxEvent: (
preferences: GSDPreferences | undefined,
message: string,
level?: CmuxLogLevel,
) => void;
// State and cache functions
invalidateAllCaches: () => void;
deriveState: (basePath: string) => Promise<GSDState>;
rebuildState: (basePath: string) => Promise<void>;
loadEffectiveGSDPreferences: () =>
| { preferences?: GSDPreferences }
| undefined;
// Pre-dispatch health gate
preDispatchHealthGate: (
basePath: string,
) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>;
// Worktree sync
syncProjectRootToWorktree: (
originalBase: string,
basePath: string,
milestoneId: string | null,
) => void;
// Resource version guard
checkResourcesStale: (version: string | null) => string | null;
// Session lock
validateSessionLock: (basePath: string) => SessionLockStatus;
updateSessionLock: (
basePath: string,
unitType: string,
unitId: string,
completedUnits: number,
sessionFile?: string,
) => void;
handleLostSessionLock: (
ctx?: ExtensionContext,
lockStatus?: SessionLockStatus,
) => void;
// Milestone transition functions
sendDesktopNotification: (
title: string,
body: string,
kind: string,
category: string,
) => void;
setActiveMilestoneId: (basePath: string, mid: string) => void;
pruneQueueOrder: (basePath: string, pendingIds: string[]) => void;
isInAutoWorktree: (basePath: string) => boolean;
shouldUseWorktreeIsolation: () => boolean;
mergeMilestoneToMain: (
basePath: string,
milestoneId: string,
roadmapContent: string,
) => { pushed: boolean };
teardownAutoWorktree: (basePath: string, milestoneId: string) => void;
createAutoWorktree: (basePath: string, milestoneId: string) => string;
captureIntegrationBranch: (
basePath: string,
mid: string,
opts?: { commitDocs?: boolean },
) => void;
getIsolationMode: () => string;
getCurrentBranch: (basePath: string) => string;
autoWorktreeBranch: (milestoneId: string) => string;
resolveMilestoneFile: (
basePath: string,
milestoneId: string,
fileType: string,
) => string | null;
reconcileMergeState: (basePath: string, ctx: ExtensionContext) => boolean;
// Budget/context/secrets
getLedger: () => unknown;
getProjectTotals: (units: unknown) => { cost: number };
formatCost: (cost: number) => string;
getBudgetAlertLevel: (pct: number) => number;
getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number;
getBudgetEnforcementAction: (enforcement: string, pct: number) => string;
getManifestStatus: (
basePath: string,
mid: string | undefined,
projectRoot?: string,
) => Promise<{ pending: unknown[] } | null>;
collectSecretsFromManifest: (
basePath: string,
mid: string | undefined,
ctx: ExtensionContext,
) => Promise<{
applied: unknown[];
skipped: unknown[];
existingSkipped: unknown[];
} | null>;
// Dispatch
resolveDispatch: (dctx: {
basePath: string;
mid: string;
midTitle: string;
state: GSDState;
prefs: GSDPreferences | undefined;
session?: AutoSession;
}) => Promise<DispatchAction>;
runPreDispatchHooks: (
unitType: string,
unitId: string,
prompt: string,
basePath: string,
) => {
firedHooks: string[];
action: string;
prompt?: string;
unitType?: string;
model?: string;
};
getPriorSliceCompletionBlocker: (
basePath: string,
mainBranch: string,
unitType: string,
unitId: string,
) => string | null;
getMainBranch: (basePath: string) => string;
collectObservabilityWarnings: (
ctx: ExtensionContext,
basePath: string,
unitType: string,
unitId: string,
) => Promise<unknown[]>;
buildObservabilityRepairBlock: (issues: unknown[]) => string | null;
// Unit closeout + runtime records
closeoutUnit: (
ctx: ExtensionContext,
basePath: string,
unitType: string,
unitId: string,
startedAt: number,
opts?: CloseoutOptions & Record<string, unknown>,
) => Promise<void>;
verifyExpectedArtifact: (
unitType: string,
unitId: string,
basePath: string,
) => boolean;
clearUnitRuntimeRecord: (
basePath: string,
unitType: string,
unitId: string,
) => void;
writeUnitRuntimeRecord: (
basePath: string,
unitType: string,
unitId: string,
startedAt: number,
record: Record<string, unknown>,
) => void;
recordOutcome: (unitType: string, tier: string, success: boolean) => void;
writeLock: (
lockBase: string,
unitType: string,
unitId: string,
completedCount: number,
sessionFile?: string,
) => void;
captureAvailableSkills: () => void;
ensurePreconditions: (
unitType: string,
unitId: string,
basePath: string,
state: GSDState,
) => void;
updateSliceProgressCache: (
basePath: string,
mid: string,
sliceId?: string,
) => void;
// Model selection + supervision
selectAndApplyModel: (
ctx: ExtensionContext,
pi: ExtensionAPI,
unitType: string,
unitId: string,
basePath: string,
prefs: GSDPreferences | undefined,
verbose: boolean,
startModel: { provider: string; id: string } | null,
retryContext?: { isRetry: boolean; previousTier?: string },
) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>;
resolveModelId: <T extends { id: string; provider: string }>(
modelId: string,
availableModels: T[],
currentProvider: string | undefined,
) => T | undefined;
startUnitSupervision: (sctx: {
s: AutoSession;
ctx: ExtensionContext;
pi: ExtensionAPI;
unitType: string;
unitId: string;
prefs: GSDPreferences | undefined;
buildSnapshotOpts: () => CloseoutOptions & Record<string, unknown>;
buildRecoveryContext: () => unknown;
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>;
}) => void;
// Prompt helpers
getDeepDiagnostic: (basePath: string) => string | null;
isDbAvailable: () => boolean;
reorderForCaching: (prompt: string) => string;
// Filesystem
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: string) => string;
atomicWriteSync: (path: string, content: string) => void;
// Git
GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown;
// WorktreeResolver
resolver: WorktreeResolver;
// Post-unit processing
postUnitPreVerification: (
pctx: PostUnitContext,
opts?: PreVerificationOpts,
) => Promise<"dispatched" | "continue" | "retry">;
runPostUnitVerification: (
vctx: VerificationContext,
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>,
) => Promise<VerificationResult>;
postUnitPostVerification: (
pctx: PostUnitContext,
) => Promise<"continue" | "step-wizard" | "stopped">;
// Session manager
getSessionFile: (ctx: ExtensionContext) => string;
// Journal
emitJournalEvent: (entry: JournalEntry) => void;
}

View file

@ -0,0 +1,229 @@
/**
* auto/loop.ts Main auto-mode execution loop.
*
* Iterates: derive dispatch guards runUnit finalize repeat.
* Exits when s.active becomes false or a terminal condition is reached.
*
* Imports from: auto/types, auto/resolve, auto/phases
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import { randomUUID } from "node:crypto";
import type { AutoSession, SidecarItem } from "./session.js";
import type { LoopDeps } from "./loop-deps.js";
import {
MAX_LOOP_ITERATIONS,
type LoopState,
type IterationContext,
type IterationData,
} from "./types.js";
import { _clearCurrentResolve } from "./resolve.js";
import {
runPreDispatch,
runDispatch,
runGuards,
runUnitPhase,
runFinalize,
} from "./phases.js";
import { debugLog } from "../debug-logger.js";
import { isInfrastructureError } from "./infra-errors.js";
/**
* Main auto-mode execution loop. Iterates: derive dispatch guards
* runUnit finalize repeat. Exits when s.active becomes false or a
* terminal condition is reached.
*
* This is the linear replacement for the recursive
* dispatchNextUnit handleAgentEnd dispatchNextUnit chain.
*/
export async function autoLoop(
ctx: ExtensionContext,
pi: ExtensionAPI,
s: AutoSession,
deps: LoopDeps,
): Promise<void> {
debugLog("autoLoop", { phase: "enter" });
let iteration = 0;
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
let consecutiveErrors = 0;
while (s.active) {
iteration++;
debugLog("autoLoop", { phase: "loop-top", iteration });
// ── Journal: per-iteration flow grouping ──
const flowId = randomUUID();
let seqCounter = 0;
const nextSeq = () => ++seqCounter;
if (iteration > MAX_LOOP_ITERATIONS) {
debugLog("autoLoop", {
phase: "exit",
reason: "max-iterations",
iteration,
});
await deps.stopAuto(
ctx,
pi,
`Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
);
break;
}
if (!s.cmdCtx) {
debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
break;
}
try {
// ── Blanket try/catch: one bad iteration must not kill the session
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
// ── Check sidecar queue before deriveState ──
let sidecarItem: SidecarItem | undefined;
if (s.sidecarQueue.length > 0) {
sidecarItem = s.sidecarQueue.shift()!;
debugLog("autoLoop", {
phase: "sidecar-dequeue",
kind: sidecarItem.kind,
unitType: sidecarItem.unitType,
unitId: sidecarItem.unitId,
});
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "sidecar-dequeue", data: { kind: sidecarItem.kind, unitType: sidecarItem.unitType, unitId: sidecarItem.unitId } });
}
const sessionLockBase = deps.lockBase();
if (sessionLockBase) {
const lockStatus = deps.validateSessionLock(sessionLockBase);
if (!lockStatus.valid) {
debugLog("autoLoop", {
phase: "session-lock-invalid",
reason: lockStatus.failureReason ?? "unknown",
existingPid: lockStatus.existingPid,
expectedPid: lockStatus.expectedPid,
});
deps.handleLostSessionLock(ctx, lockStatus);
debugLog("autoLoop", {
phase: "exit",
reason: "session-lock-lost",
detail: lockStatus.failureReason ?? "unknown",
});
break;
}
}
const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration, flowId, nextSeq };
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-start", data: { iteration } });
let iterData: IterationData;
if (!sidecarItem) {
// ── Phase 1: Pre-dispatch ─────────────────────────────────────────
const preDispatchResult = await runPreDispatch(ic, loopState);
if (preDispatchResult.action === "break") break;
if (preDispatchResult.action === "continue") continue;
const preData = preDispatchResult.data;
// ── Phase 2: Guards ───────────────────────────────────────────────
const guardsResult = await runGuards(ic, preData.mid);
if (guardsResult.action === "break") break;
// ── Phase 3: Dispatch ─────────────────────────────────────────────
const dispatchResult = await runDispatch(ic, preData, loopState);
if (dispatchResult.action === "break") break;
if (dispatchResult.action === "continue") continue;
iterData = dispatchResult.data;
} else {
// ── Sidecar path: use values from the sidecar item directly ──
const sidecarState = await deps.deriveState(s.basePath);
iterData = {
unitType: sidecarItem.unitType,
unitId: sidecarItem.unitId,
prompt: sidecarItem.prompt,
finalPrompt: sidecarItem.prompt,
pauseAfterUatDispatch: false,
observabilityIssues: [],
state: sidecarState,
mid: sidecarState.activeMilestone?.id,
midTitle: sidecarState.activeMilestone?.title,
isRetry: false, previousTier: undefined,
};
}
const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
if (unitPhaseResult.action === "break") break;
// ── Phase 5: Finalize ───────────────────────────────────────────────
const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
if (finalizeResult.action === "break") break;
if (finalizeResult.action === "continue") continue;
consecutiveErrors = 0; // Iteration completed successfully
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
debugLog("autoLoop", { phase: "iteration-complete", iteration });
} catch (loopErr) {
// ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ──
const msg = loopErr instanceof Error ? loopErr.message : String(loopErr);
// ── Infrastructure errors: immediate stop, no retry ──
// These are unrecoverable (disk full, OOM, etc.). Retrying just burns
// LLM budget on guaranteed failures.
const infraCode = isInfrastructureError(loopErr);
if (infraCode) {
debugLog("autoLoop", {
phase: "infrastructure-error",
iteration,
code: infraCode,
error: msg,
});
ctx.ui.notify(
`Auto-mode stopped: infrastructure error ${infraCode}${msg}`,
"error",
);
await deps.stopAuto(
ctx,
pi,
`Infrastructure error (${infraCode}): not recoverable by retry`,
);
break;
}
consecutiveErrors++;
debugLog("autoLoop", {
phase: "iteration-error",
iteration,
consecutiveErrors,
error: msg,
});
if (consecutiveErrors >= 3) {
// 3+ consecutive: hard stop — something is fundamentally broken
ctx.ui.notify(
`Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`,
"error",
);
await deps.stopAuto(
ctx,
pi,
`${consecutiveErrors} consecutive iteration failures`,
);
break;
} else if (consecutiveErrors === 2) {
// 2nd consecutive: try invalidating caches + re-deriving state
ctx.ui.notify(
`Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`,
"warning",
);
deps.invalidateAllCaches();
} else {
// 1st error: log and retry — transient failures happen
ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning");
}
}
}
_clearCurrentResolve();
debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
/**
* auto/resolve.ts Per-unit one-shot promise state and resolution.
*
* Module-level mutable state: `_currentResolve` and `_sessionSwitchInFlight`.
* Setter functions are exported because ES modules can't mutate `let` vars
* across module boundaries.
*
* Imports from: auto/types
*/
import type { UnitResult, AgentEndEvent } from "./types.js";
import type { AutoSession } from "./session.js";
import { debugLog } from "../debug-logger.js";
// ─── Per-unit one-shot promise state ────────────────────────────────────────
//
// A single module-level resolve function scoped to the current unit execution.
// No queue — if an agent_end arrives with no pending resolver, it is dropped
// (logged as warning). This is simpler and safer than the previous session-
// scoped pendingResolve + pendingAgentEndQueue pattern.
let _currentResolve: ((result: UnitResult) => void) | null = null;
let _sessionSwitchInFlight = false;
// ─── Setters (needed for cross-module mutation) ─────────────────────────────
export function _setCurrentResolve(fn: ((result: UnitResult) => void) | null): void {
_currentResolve = fn;
}
export function _setSessionSwitchInFlight(v: boolean): void {
_sessionSwitchInFlight = v;
}
export function _clearCurrentResolve(): void {
_currentResolve = null;
}
// ─── resolveAgentEnd ─────────────────────────────────────────────────────────
/**
* Called from the agent_end event handler in index.ts to resolve the
* in-flight unit promise. One-shot: the resolver is nulled before calling
* to prevent double-resolution from model fallback retries.
*
* If no resolver exists (event arrived between loop iterations or during
* session switch), the event is dropped with a debug warning.
*/
export function resolveAgentEnd(event: AgentEndEvent): void {
if (_sessionSwitchInFlight) {
debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
return;
}
if (_currentResolve) {
debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
const r = _currentResolve;
_currentResolve = null;
r({ status: "completed", event });
} else {
debugLog("resolveAgentEnd", {
status: "no-pending-resolve",
warning: "agent_end with no pending unit",
});
}
}
export function isSessionSwitchInFlight(): boolean {
return _sessionSwitchInFlight;
}
// ─── resolveAgentEndCancelled ─────────────────────────────────────────────────
/**
* Force-resolve the pending unit promise with { status: "cancelled" }.
*
* Used by pauseAuto, handleAgentEnd early-return, and supervision catch
* blocks to ensure the autoLoop is never stuck awaiting a promise that
* will never resolve. Safe to call when no resolver is pending (no-op).
*/
export function resolveAgentEndCancelled(): void {
if (_currentResolve) {
debugLog("resolveAgentEndCancelled", { status: "resolving-cancelled" });
const r = _currentResolve;
_currentResolve = null;
r({ status: "cancelled" });
}
}
// ─── resetPendingResolve (test helper) ───────────────────────────────────────
/**
* Reset module-level promise state. Only exported for test cleanup
* production code should never call this.
*/
export function _resetPendingResolve(): void {
_currentResolve = null;
_sessionSwitchInFlight = false;
}
/**
* No-op for backward compatibility with tests that previously set the
* active session. The module no longer holds a session reference.
*/
export function _setActiveSession(_session: AutoSession | null): void {
// No-op — kept for test backward compatibility
}

View file

@ -0,0 +1,123 @@
/**
* auto/run-unit.ts Single unit execution: session create prompt await agent_end.
*
* Imports from: auto/types, auto/resolve
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
import type { UnitResult } from "./types.js";
import { _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js";
import { debugLog } from "../debug-logger.js";
/**
* Execute a single unit: create a new session, send the prompt, and await
* the agent_end promise. Returns a UnitResult describing what happened.
*
* The promise is one-shot: resolveAgentEnd() is the only way to resolve it.
* On session creation failure or timeout, returns { status: 'cancelled' }
* without awaiting the promise.
*/
export async function runUnit(
ctx: ExtensionContext,
pi: ExtensionAPI,
s: AutoSession,
unitType: string,
unitId: string,
prompt: string,
): Promise<UnitResult> {
debugLog("runUnit", { phase: "start", unitType, unitId });
// ── Session creation with timeout ──
debugLog("runUnit", { phase: "session-create", unitType, unitId });
let sessionResult: { cancelled: boolean };
let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
_setSessionSwitchInFlight(true);
try {
const sessionPromise = s.cmdCtx!.newSession().finally(() => {
_setSessionSwitchInFlight(false);
});
const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
sessionTimeoutHandle = setTimeout(
() => resolve({ cancelled: true }),
NEW_SESSION_TIMEOUT_MS,
);
});
sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
} catch (sessionErr) {
if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
const msg =
sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
debugLog("runUnit", {
phase: "session-error",
unitType,
unitId,
error: msg,
});
return { status: "cancelled" };
}
if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
if (sessionResult.cancelled) {
debugLog("runUnit-session-timeout", { unitType, unitId });
return { status: "cancelled" };
}
if (!s.active) {
return { status: "cancelled" };
}
// ── Create the agent_end promise (per-unit one-shot) ──
// This happens after newSession completes so session-switch agent_end events
// from the previous session cannot resolve the new unit.
_setSessionSwitchInFlight(false);
const unitPromise = new Promise<UnitResult>((resolve) => {
_setCurrentResolve(resolve);
});
// Ensure cwd matches basePath before dispatch (#1389).
// async_bash and background jobs can drift cwd away from the worktree.
// Realigning here prevents commits from landing on the wrong branch.
try {
if (process.cwd() !== s.basePath) {
process.chdir(s.basePath);
}
} catch { /* non-fatal — chdir may fail if dir was removed */ }
// ── Send the prompt ──
debugLog("runUnit", { phase: "send-message", unitType, unitId });
pi.sendMessage(
{ customType: "gsd-auto", content: prompt, display: s.verbose },
{ triggerTurn: true },
);
// ── Await agent_end ──
debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId });
const result = await unitPromise;
debugLog("runUnit", {
phase: "agent-end-received",
unitType,
unitId,
status: result.status,
});
// Discard trailing follow-up messages (e.g. async_job_result notifications)
// from the completed unit. Without this, queued follow-ups trigger wasteful
// LLM turns before the next session can start (#1642).
// clearQueue() lives on AgentSession but isn't part of the typed
// ExtensionCommandContext interface — call it via runtime check.
try {
const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
if (typeof cmdCtxAny?.clearQueue === "function") {
(cmdCtxAny.clearQueue as () => unknown)();
}
} catch {
// Non-fatal — clearQueue may not be available in all contexts
}
return result;
}

View file

@ -0,0 +1,105 @@
/**
* auto/types.ts Constants and types shared across auto-loop modules.
*
* Leaf node in the import DAG no imports from auto/.
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import type { GSDPreferences } from "../preferences.js";
import type { GSDState } from "../types.js";
import type { CmuxLogLevel } from "../../cmux/index.js";
import type { LoopDeps } from "./loop-deps.js";
/**
* Maximum total loop iterations before forced stop. Prevents runaway loops
* when units alternate IDs (bypassing the same-unit stuck detector).
* A milestone with 20 slices × 5 tasks × 3 phases 300 units. 500 gives
* generous headroom including retries and sidecar work.
*/
export const MAX_LOOP_ITERATIONS = 500;
/** Maximum characters of failure/crash context included in recovery prompts. */
export const MAX_RECOVERY_CHARS = 50_000;
/** Data-driven budget threshold notifications (descending). The 100% entry
* triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
* a simple notification. */
export const BUDGET_THRESHOLDS: Array<{
pct: number;
label: string;
notifyLevel: "info" | "warning" | "error";
cmuxLevel: "progress" | "warning" | "error";
}> = [
{ pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
{ pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
{ pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
{ pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
];
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* Minimal shape of the event parameter from pi.on("agent_end", ...).
* The full event has more fields, but the loop only needs messages.
*/
export interface AgentEndEvent {
messages: unknown[];
}
/**
* Result of a single unit execution (one iteration of the loop).
*/
export interface UnitResult {
status: "completed" | "cancelled" | "error";
event?: AgentEndEvent;
}
// ─── Phase pipeline types ────────────────────────────────────────────────────
export type PhaseResult<T = void> =
| { action: "continue" }
| { action: "break"; reason: string }
| { action: "next"; data: T }
export interface IterationContext {
ctx: ExtensionContext;
pi: ExtensionAPI;
s: AutoSession;
deps: LoopDeps;
prefs: GSDPreferences | undefined;
iteration: number;
/** UUID grouping all journal events for this iteration. */
flowId: string;
/** Returns the next monotonically increasing sequence number (1-based, reset per iteration). */
nextSeq: () => number;
}
export interface LoopState {
recentUnits: Array<{ key: string; error?: string }>;
stuckRecoveryAttempts: number;
}
export interface PreDispatchData {
state: GSDState;
mid: string;
midTitle: string;
}
export interface IterationData {
unitType: string;
unitId: string;
prompt: string;
finalPrompt: string;
pauseAfterUatDispatch: boolean;
observabilityIssues: unknown[];
state: GSDState;
mid: string | undefined;
midTitle: string | undefined;
isRetry: boolean;
previousTier: string | undefined;
/** Model override from pre-dispatch hooks (applied after standard model selection). */
hookModelOverride?: string;
}
export type WindowEntry = { key: string; error?: string };

View file

@ -1,23 +1,75 @@
import { Type } from "@sinclair/typebox";
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { findMilestoneIds, nextMilestoneId } from "../guided-flow.js";
import { findMilestoneIds, nextMilestoneId, claimReservedId, getReservedMilestoneIds } from "../guided-flow.js";
import { loadEffectiveGSDPreferences } from "../preferences.js";
import { ensureDbOpen } from "./dynamic-tools.js";
export function registerDbTools(pi: ExtensionAPI): void {
/**
* Register an alias tool that shares the same execute function as its canonical counterpart.
* The alias description and promptGuidelines direct the LLM to prefer the canonical name.
*/
function registerAlias(pi: ExtensionAPI, toolDef: any, aliasName: string, canonicalName: string): void {
pi.registerTool({
name: "gsd_save_decision",
...toolDef,
name: aliasName,
description: toolDef.description + ` (alias for ${canonicalName} — prefer the canonical name)`,
promptGuidelines: [`Alias for ${canonicalName} — prefer the canonical name.`],
});
}
export function registerDbTools(pi: ExtensionAPI): void {
// ─── gsd_decision_save (formerly gsd_save_decision) ─────────────────────
const decisionSaveExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }],
details: { operation: "save_decision", error: "db_unavailable" } as any,
};
}
try {
const { saveDecisionToDb } = await import("../db-writer.js");
const { id } = await saveDecisionToDb(
{
scope: params.scope,
decision: params.decision,
choice: params.choice,
rationale: params.rationale,
revisable: params.revisable,
when_context: params.when_context,
made_by: params.made_by,
},
process.cwd(),
);
return {
content: [{ type: "text" as const, text: `Saved decision ${id}` }],
details: { operation: "save_decision", id } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_decision_save tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }],
details: { operation: "save_decision", error: msg } as any,
};
}
};
const decisionSaveTool = {
name: "gsd_decision_save",
label: "Save Decision",
description:
"Record a project decision to the GSD database and regenerate DECISIONS.md. " +
"Decision IDs are auto-assigned — never provide an ID manually.",
promptSnippet: "Record a project decision to the GSD database (auto-assigns ID, regenerates DECISIONS.md)",
promptGuidelines: [
"Use gsd_save_decision when recording an architectural, pattern, library, or observability decision.",
"Use gsd_decision_save when recording an architectural, pattern, library, or observability decision.",
"Decision IDs are auto-assigned (D001, D002, ...) — never guess or provide an ID.",
"All fields except revisable and when_context are required.",
"All fields except revisable, when_context, and made_by are required.",
"The tool writes to the DB and regenerates .gsd/DECISIONS.md automatically.",
"Set made_by to 'human' when the user explicitly directed the decision, 'agent' when the LLM chose autonomously (default), or 'collaborative' when it was discussed and agreed together.",
],
parameters: Type.Object({
scope: Type.String({ description: "Scope of the decision (e.g. 'architecture', 'library', 'observability')" }),
@ -26,52 +78,69 @@ export function registerDbTools(pi: ExtensionAPI): void {
rationale: Type.String({ description: "Why this choice was made" }),
revisable: Type.Optional(Type.String({ description: "Whether this can be revisited (default: 'Yes')" })),
when_context: Type.Optional(Type.String({ description: "When/context for the decision (e.g. milestone ID)" })),
made_by: Type.Optional(Type.Union([
Type.Literal("human"),
Type.Literal("agent"),
Type.Literal("collaborative"),
], { description: "Who made this decision: 'human' (user directed), 'agent' (LLM decided autonomously), or 'collaborative' (discussed and agreed). Default: 'agent'" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }],
details: { operation: "save_decision", error: "db_unavailable" } as any,
};
}
try {
const { saveDecisionToDb } = await import("../db-writer.js");
const { id } = await saveDecisionToDb(
{
scope: params.scope,
decision: params.decision,
choice: params.choice,
rationale: params.rationale,
revisable: params.revisable,
when_context: params.when_context,
},
process.cwd(),
);
return {
content: [{ type: "text" as const, text: `Saved decision ${id}` }],
details: { operation: "save_decision", id } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_save_decision tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }],
details: { operation: "save_decision", error: msg } as any,
};
}
},
});
execute: decisionSaveExecute,
};
pi.registerTool({
name: "gsd_update_requirement",
pi.registerTool(decisionSaveTool);
registerAlias(pi, decisionSaveTool, "gsd_save_decision", "gsd_decision_save");
// ─── gsd_requirement_update (formerly gsd_update_requirement) ───────────
const requirementUpdateExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }],
details: { operation: "update_requirement", id: params.id, error: "db_unavailable" } as any,
};
}
try {
const db = await import("../gsd-db.js");
const existing = db.getRequirementById(params.id);
if (!existing) {
return {
content: [{ type: "text" as const, text: `Error: Requirement ${params.id} not found.` }],
details: { operation: "update_requirement", id: params.id, error: "not_found" } as any,
};
}
const { updateRequirementInDb } = await import("../db-writer.js");
const updates: Record<string, string | undefined> = {};
if (params.status !== undefined) updates.status = params.status;
if (params.validation !== undefined) updates.validation = params.validation;
if (params.notes !== undefined) updates.notes = params.notes;
if (params.description !== undefined) updates.description = params.description;
if (params.primary_owner !== undefined) updates.primary_owner = params.primary_owner;
if (params.supporting_slices !== undefined) updates.supporting_slices = params.supporting_slices;
await updateRequirementInDb(params.id, updates, process.cwd());
return {
content: [{ type: "text" as const, text: `Updated requirement ${params.id}` }],
details: { operation: "update_requirement", id: params.id } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_requirement_update tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }],
details: { operation: "update_requirement", id: params.id, error: msg } as any,
};
}
};
const requirementUpdateTool = {
name: "gsd_requirement_update",
label: "Update Requirement",
description:
"Update an existing requirement in the GSD database and regenerate REQUIREMENTS.md. " +
"Provide the requirement ID (e.g. R001) and any fields to update.",
promptSnippet: "Update an existing GSD requirement by ID (regenerates REQUIREMENTS.md)",
promptGuidelines: [
"Use gsd_update_requirement to change status, validation, notes, or other fields on an existing requirement.",
"Use gsd_requirement_update to change status, validation, notes, or other fields on an existing requirement.",
"The id parameter is required — it must be an existing RXXX identifier.",
"All other fields are optional — only provided fields are updated.",
"The tool verifies the requirement exists before updating.",
@ -85,56 +154,73 @@ export function registerDbTools(pi: ExtensionAPI): void {
primary_owner: Type.Optional(Type.String({ description: "Primary owning slice" })),
supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }],
details: { operation: "update_requirement", id: params.id, error: "db_unavailable" } as any,
};
}
try {
const db = await import("../gsd-db.js");
const existing = db.getRequirementById(params.id);
if (!existing) {
return {
content: [{ type: "text" as const, text: `Error: Requirement ${params.id} not found.` }],
details: { operation: "update_requirement", id: params.id, error: "not_found" } as any,
};
}
const { updateRequirementInDb } = await import("../db-writer.js");
const updates: Record<string, string | undefined> = {};
if (params.status !== undefined) updates.status = params.status;
if (params.validation !== undefined) updates.validation = params.validation;
if (params.notes !== undefined) updates.notes = params.notes;
if (params.description !== undefined) updates.description = params.description;
if (params.primary_owner !== undefined) updates.primary_owner = params.primary_owner;
if (params.supporting_slices !== undefined) updates.supporting_slices = params.supporting_slices;
await updateRequirementInDb(params.id, updates, process.cwd());
return {
content: [{ type: "text" as const, text: `Updated requirement ${params.id}` }],
details: { operation: "update_requirement", id: params.id } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_update_requirement tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }],
details: { operation: "update_requirement", id: params.id, error: msg } as any,
};
}
},
});
execute: requirementUpdateExecute,
};
pi.registerTool({
name: "gsd_save_summary",
pi.registerTool(requirementUpdateTool);
registerAlias(pi, requirementUpdateTool, "gsd_update_requirement", "gsd_requirement_update");
// ─── gsd_summary_save (formerly gsd_save_summary) ──────────────────────
const summarySaveExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }],
details: { operation: "save_summary", error: "db_unavailable" } as any,
};
}
const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"];
if (!validTypes.includes(params.artifact_type)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }],
details: { operation: "save_summary", error: "invalid_artifact_type" } as any,
};
}
try {
let relativePath: string;
if (params.task_id && params.slice_id) {
relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`;
} else if (params.slice_id) {
relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`;
} else {
relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`;
}
const { saveArtifactToDb } = await import("../db-writer.js");
await saveArtifactToDb(
{
path: relativePath,
artifact_type: params.artifact_type,
content: params.content,
milestone_id: params.milestone_id,
slice_id: params.slice_id,
task_id: params.task_id,
},
process.cwd(),
);
return {
content: [{ type: "text" as const, text: `Saved ${params.artifact_type} artifact to ${relativePath}` }],
details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_summary_save tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }],
details: { operation: "save_summary", error: msg } as any,
};
}
};
const summarySaveTool = {
name: "gsd_summary_save",
label: "Save Summary",
description:
"Save a summary, research, context, or assessment artifact to the GSD database and write it to disk. " +
"Computes the file path from milestone/slice/task IDs automatically.",
promptSnippet: "Save a GSD artifact (summary/research/context/assessment) to DB and disk",
promptGuidelines: [
"Use gsd_save_summary to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).",
"Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).",
"milestone_id is required. slice_id and task_id are optional — they determine the file path.",
"The tool computes the relative path automatically: milestones/M001/M001-SUMMARY.md, milestones/M001/slices/S01/S01-SUMMARY.md, etc.",
"artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT.",
@ -146,60 +232,46 @@ export function registerDbTools(pi: ExtensionAPI): void {
artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT" }),
content: Type.String({ description: "The full markdown content of the artifact" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }],
details: { operation: "save_summary", error: "db_unavailable" } as any,
};
}
const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"];
if (!validTypes.includes(params.artifact_type)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }],
details: { operation: "save_summary", error: "invalid_artifact_type" } as any,
};
}
try {
let relativePath: string;
if (params.task_id && params.slice_id) {
relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`;
} else if (params.slice_id) {
relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`;
} else {
relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`;
}
const { saveArtifactToDb } = await import("../db-writer.js");
await saveArtifactToDb(
{
path: relativePath,
artifact_type: params.artifact_type,
content: params.content,
milestone_id: params.milestone_id,
slice_id: params.slice_id,
task_id: params.task_id,
},
process.cwd(),
);
return {
content: [{ type: "text" as const, text: `Saved ${params.artifact_type} artifact to ${relativePath}` }],
details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: gsd_save_summary tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }],
details: { operation: "save_summary", error: msg } as any,
};
}
},
});
execute: summarySaveExecute,
};
const reservedMilestoneIds = new Set<string>();
pi.registerTool({
name: "gsd_generate_milestone_id",
pi.registerTool(summarySaveTool);
registerAlias(pi, summarySaveTool, "gsd_save_summary", "gsd_summary_save");
// ─── gsd_milestone_generate_id (formerly gsd_generate_milestone_id) ────
const milestoneGenerateIdExecute = async (_toolCallId: any, _params: any, _signal: any, _onUpdate: any, _ctx: any) => {
try {
// Claim a reserved ID if the guided-flow already previewed one to the user.
// This guarantees the ID shown in the UI matches the one materialised on disk.
const reserved = claimReservedId();
if (reserved) {
return {
content: [{ type: "text" as const, text: reserved }],
details: { operation: "generate_milestone_id", id: reserved, source: "reserved" } as any,
};
}
const basePath = process.cwd();
const existingIds = findMilestoneIds(basePath);
const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])];
const newId = nextMilestoneId(allIds, uniqueEnabled);
return {
content: [{ type: "text" as const, text: newId }],
details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, uniqueEnabled } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }],
details: { operation: "generate_milestone_id", error: msg } as any,
};
}
};
const milestoneGenerateIdTool = {
name: "gsd_milestone_generate_id",
label: "Generate Milestone ID",
description:
"Generate the next milestone ID for a new GSD milestone. " +
@ -207,32 +279,15 @@ export function registerDbTools(pi: ExtensionAPI): void {
"Always use this tool when creating a new milestone — never invent milestone IDs manually.",
promptSnippet: "Generate a valid milestone ID (respects unique_milestone_ids preference)",
promptGuidelines: [
"ALWAYS call gsd_generate_milestone_id before creating a new milestone directory or writing milestone files.",
"ALWAYS call gsd_milestone_generate_id before creating a new milestone directory or writing milestone files.",
"Never invent or hardcode milestone IDs like M001, M002 — always use this tool.",
"Call it once per milestone you need to create. For multi-milestone projects, call it once for each milestone in sequence.",
"The tool returns the correct format based on project preferences (e.g. M001 or M001-r5jzab).",
],
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
try {
const basePath = process.cwd();
const existingIds = findMilestoneIds(basePath);
const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const allIds = [...new Set([...existingIds, ...reservedMilestoneIds])];
const newId = nextMilestoneId(allIds, uniqueEnabled);
reservedMilestoneIds.add(newId);
return {
content: [{ type: "text" as const, text: newId }],
details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, reservedCount: reservedMilestoneIds.size, uniqueEnabled } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }],
details: { operation: "generate_milestone_id", error: msg } as any,
};
}
},
});
}
execute: milestoneGenerateIdExecute,
};
pi.registerTool(milestoneGenerateIdTool);
registerAlias(pi, milestoneGenerateIdTool, "gsd_generate_milestone_id", "gsd_milestone_generate_id");
}

Some files were not shown because too many files have changed in this diff Show more