sf snapshot: pre-dispatch, uncommitted changes after 53m inactivity
This commit is contained in:
parent
40e0835d5e
commit
2111da8e60
169 changed files with 1369 additions and 1791 deletions
482
.gsd/CODEBASE.md
482
.gsd/CODEBASE.md
|
|
@ -1,482 +0,0 @@
|
|||
# Codebase Map
|
||||
|
||||
Generated: 2026-04-15T12:09:27Z | Files: 500 | Described: 0/500
|
||||
<!-- gsd:codebase-meta {"generatedAt":"2026-04-15T12:09:27Z","fingerprint":"447265c2205a9bc92066b5de4a0866717d17b961","fileCount":500,"truncated":true} -->
|
||||
Note: Truncated to first 500 files. Run with higher --max-files to include all.
|
||||
|
||||
### (root)/
|
||||
- `.dockerignore`
|
||||
- `.gitignore`
|
||||
- `.npmignore`
|
||||
- `.npmrc`
|
||||
- `.prompt-injection-scanignore`
|
||||
- `.secretscanignore`
|
||||
- `CHANGELOG.md`
|
||||
- `CONTRIBUTING.md`
|
||||
- `Dockerfile`
|
||||
- `flake.nix`
|
||||
- `LICENSE`
|
||||
- `package-lock.json`
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
- `VISION.md`
|
||||
|
||||
### .github/
|
||||
- `.github/CODEOWNERS`
|
||||
- `.github/FUNDING.yml`
|
||||
- `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
### .github/ISSUE_TEMPLATE/
|
||||
- `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||
- `.github/ISSUE_TEMPLATE/config.yml`
|
||||
- `.github/ISSUE_TEMPLATE/feature_request.yml`
|
||||
|
||||
### .github/workflows/
|
||||
- `.github/workflows/ai-triage.yml`
|
||||
- `.github/workflows/build-native.yml`
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.github/workflows/cleanup-dev-versions.yml`
|
||||
- `.github/workflows/pipeline.yml`
|
||||
- `.github/workflows/pr-risk.yml`
|
||||
|
||||
### bin/
|
||||
- `bin/gsd-from-source`
|
||||
|
||||
### docker/
|
||||
- `docker/.env.example`
|
||||
- `docker/bootstrap.sh`
|
||||
- `docker/docker-compose.full.yaml`
|
||||
- `docker/docker-compose.yaml`
|
||||
- `docker/Dockerfile.ci-builder`
|
||||
- `docker/Dockerfile.sandbox`
|
||||
- `docker/entrypoint.sh`
|
||||
- `docker/README.md`
|
||||
|
||||
### docs/
|
||||
- `docs/README.md`
|
||||
|
||||
### docs/dev/
|
||||
- `docs/dev/ADR-001-branchless-worktree-architecture.md`
|
||||
- `docs/dev/ADR-003-pipeline-simplification.md`
|
||||
- `docs/dev/ADR-004-capability-aware-model-routing.md`
|
||||
- `docs/dev/ADR-005-multi-model-provider-tool-strategy.md`
|
||||
- `docs/dev/ADR-007-model-catalog-split.md`
|
||||
- `docs/dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md`
|
||||
- `docs/dev/ADR-008-IMPLEMENTATION-PLAN.md`
|
||||
- `docs/dev/ADR-009-IMPLEMENTATION-PLAN.md`
|
||||
- `docs/dev/ADR-009-orchestration-kernel-refactor.md`
|
||||
- `docs/dev/ADR-010-pi-clean-seam-architecture.md`
|
||||
- `docs/dev/agent-knowledge-index.md`
|
||||
- `docs/dev/architecture.md`
|
||||
- `docs/dev/ci-cd-pipeline.md`
|
||||
- `docs/dev/FILE-SYSTEM-MAP.md`
|
||||
- `docs/dev/FRONTIER-TECHNIQUES.md`
|
||||
- `docs/dev/pi-context-optimization-opportunities.md`
|
||||
- `docs/dev/PRD-branchless-worktree-architecture.md`
|
||||
- `docs/dev/PRD-pi-clean-seam-refactor.md`
|
||||
|
||||
### docs/dev/building-coding-agents/
|
||||
- *(27 files: 27 .md)*
|
||||
|
||||
### docs/dev/context-and-hooks/
|
||||
- `docs/dev/context-and-hooks/01-the-context-pipeline.md`
|
||||
- `docs/dev/context-and-hooks/02-hook-reference.md`
|
||||
- `docs/dev/context-and-hooks/03-context-injection-patterns.md`
|
||||
- `docs/dev/context-and-hooks/04-message-types-and-llm-visibility.md`
|
||||
- `docs/dev/context-and-hooks/05-inter-extension-communication.md`
|
||||
- `docs/dev/context-and-hooks/06-advanced-patterns-from-source.md`
|
||||
- `docs/dev/context-and-hooks/07-the-system-prompt-anatomy.md`
|
||||
- `docs/dev/context-and-hooks/README.md`
|
||||
|
||||
### docs/dev/extending-pi/
|
||||
- *(26 files: 26 .md)*
|
||||
|
||||
### docs/dev/pi-ui-tui/
|
||||
- *(24 files: 24 .md)*
|
||||
|
||||
### docs/dev/proposals/
|
||||
- `docs/dev/proposals/698-browser-tools-feature-additions.md`
|
||||
- `docs/dev/proposals/rfc-gitops-branching-strategy.md`
|
||||
|
||||
### docs/dev/proposals/workflows/
|
||||
- `docs/dev/proposals/workflows/backmerge.yml`
|
||||
- `docs/dev/proposals/workflows/create-release.yml`
|
||||
- `docs/dev/proposals/workflows/README.md`
|
||||
- `docs/dev/proposals/workflows/sync-next.yml`
|
||||
|
||||
### docs/dev/superpowers/plans/
|
||||
- `docs/dev/superpowers/plans/2026-03-17-cicd-pipeline.md`
|
||||
|
||||
### docs/dev/superpowers/specs/
|
||||
- `docs/dev/superpowers/specs/2026-03-17-cicd-pipeline-design.md`
|
||||
|
||||
### docs/dev/what-is-pi/
|
||||
- `docs/dev/what-is-pi/01-what-pi-is.md`
|
||||
- `docs/dev/what-is-pi/02-design-philosophy.md`
|
||||
- `docs/dev/what-is-pi/03-the-four-modes-of-operation.md`
|
||||
- `docs/dev/what-is-pi/04-the-architecture-how-everything-fits-together.md`
|
||||
- `docs/dev/what-is-pi/05-the-agent-loop-how-pi-thinks.md`
|
||||
- `docs/dev/what-is-pi/06-tools-how-pi-acts-on-the-world.md`
|
||||
- `docs/dev/what-is-pi/07-sessions-memory-that-branches.md`
|
||||
- `docs/dev/what-is-pi/08-compaction-how-pi-manages-context-limits.md`
|
||||
- `docs/dev/what-is-pi/09-the-customization-stack.md`
|
||||
- `docs/dev/what-is-pi/10-providers-models-multi-model-by-default.md`
|
||||
- `docs/dev/what-is-pi/11-the-interactive-tui.md`
|
||||
- `docs/dev/what-is-pi/12-the-message-queue-talking-while-pi-thinks.md`
|
||||
- `docs/dev/what-is-pi/13-context-files-project-instructions.md`
|
||||
- `docs/dev/what-is-pi/14-the-sdk-rpc-embedding-pi.md`
|
||||
- `docs/dev/what-is-pi/15-pi-packages-the-ecosystem.md`
|
||||
- `docs/dev/what-is-pi/16-why-pi-matters-what-makes-it-different.md`
|
||||
- `docs/dev/what-is-pi/17-file-reference-all-documentation.md`
|
||||
- `docs/dev/what-is-pi/18-quick-reference-commands-shortcuts.md`
|
||||
- `docs/dev/what-is-pi/19-building-branded-apps-on-top-of-pi.md`
|
||||
- `docs/dev/what-is-pi/README.md`
|
||||
|
||||
### docs/user-docs/
|
||||
- *(21 files: 21 .md)*
|
||||
|
||||
### docs/zh-CN/
|
||||
- `docs/zh-CN/README.md`
|
||||
|
||||
### docs/zh-CN/user-docs/
|
||||
- *(21 files: 21 .md)*
|
||||
|
||||
### gitbook/
|
||||
- `gitbook/README.md`
|
||||
- `gitbook/SUMMARY.md`
|
||||
|
||||
### gitbook/configuration/
|
||||
- `gitbook/configuration/custom-models.md`
|
||||
- `gitbook/configuration/git-settings.md`
|
||||
- `gitbook/configuration/mcp-servers.md`
|
||||
- `gitbook/configuration/notifications.md`
|
||||
- `gitbook/configuration/preferences.md`
|
||||
- `gitbook/configuration/providers.md`
|
||||
|
||||
### gitbook/core-concepts/
|
||||
- `gitbook/core-concepts/auto-mode.md`
|
||||
- `gitbook/core-concepts/project-structure.md`
|
||||
- `gitbook/core-concepts/step-mode.md`
|
||||
|
||||
### gitbook/features/
|
||||
- `gitbook/features/captures.md`
|
||||
- `gitbook/features/cost-management.md`
|
||||
- `gitbook/features/dynamic-model-routing.md`
|
||||
- `gitbook/features/github-sync.md`
|
||||
- `gitbook/features/headless.md`
|
||||
- `gitbook/features/parallel.md`
|
||||
- `gitbook/features/remote-questions.md`
|
||||
- `gitbook/features/skills.md`
|
||||
- `gitbook/features/teams.md`
|
||||
- `gitbook/features/token-optimization.md`
|
||||
- `gitbook/features/visualizer.md`
|
||||
- `gitbook/features/web-interface.md`
|
||||
- `gitbook/features/workflow-templates.md`
|
||||
|
||||
### gitbook/getting-started/
|
||||
- `gitbook/getting-started/choosing-a-model.md`
|
||||
- `gitbook/getting-started/first-project.md`
|
||||
- `gitbook/getting-started/installation.md`
|
||||
|
||||
### gitbook/reference/
|
||||
- `gitbook/reference/cli-flags.md`
|
||||
- `gitbook/reference/commands.md`
|
||||
- `gitbook/reference/environment-variables.md`
|
||||
- `gitbook/reference/keyboard-shortcuts.md`
|
||||
- `gitbook/reference/migration.md`
|
||||
- `gitbook/reference/troubleshooting.md`
|
||||
|
||||
### sf-orchestrator/
|
||||
- `sf-orchestrator/SKILL.md`
|
||||
|
||||
### sf-orchestrator/references/
|
||||
- `sf-orchestrator/references/answer-injection.md`
|
||||
- `sf-orchestrator/references/commands.md`
|
||||
- `sf-orchestrator/references/json-result.md`
|
||||
|
||||
### sf-orchestrator/templates/
|
||||
- `sf-orchestrator/templates/spec.md`
|
||||
|
||||
### sf-orchestrator/workflows/
|
||||
- `sf-orchestrator/workflows/build-from-spec.md`
|
||||
- `sf-orchestrator/workflows/monitor-and-poll.md`
|
||||
- `sf-orchestrator/workflows/step-by-step.md`
|
||||
|
||||
### mintlify-docs/
|
||||
- `mintlify-docs/docs`
|
||||
- `mintlify-docs/docs.json`
|
||||
- `mintlify-docs/getting-started.mdx`
|
||||
- `mintlify-docs/introduction.mdx`
|
||||
|
||||
### mintlify-docs/guides/
|
||||
- `mintlify-docs/guides/auto-mode.mdx`
|
||||
- `mintlify-docs/guides/captures-triage.mdx`
|
||||
- `mintlify-docs/guides/change-management.mdx`
|
||||
- `mintlify-docs/guides/commands.mdx`
|
||||
- `mintlify-docs/guides/configuration.mdx`
|
||||
- `mintlify-docs/guides/cost-management.mdx`
|
||||
- `mintlify-docs/guides/custom-models.mdx`
|
||||
- `mintlify-docs/guides/dynamic-model-routing.mdx`
|
||||
- `mintlify-docs/guides/git-strategy.mdx`
|
||||
- `mintlify-docs/guides/migration.mdx`
|
||||
- `mintlify-docs/guides/parallel-orchestration.mdx`
|
||||
- `mintlify-docs/guides/remote-questions.mdx`
|
||||
- `mintlify-docs/guides/skills.mdx`
|
||||
- `mintlify-docs/guides/token-optimization.mdx`
|
||||
- `mintlify-docs/guides/troubleshooting.mdx`
|
||||
- `mintlify-docs/guides/visualizer.mdx`
|
||||
- `mintlify-docs/guides/web-interface.mdx`
|
||||
- `mintlify-docs/guides/working-in-teams.mdx`
|
||||
|
||||
### native/
|
||||
- `native/.gitignore`
|
||||
- `native/.npmignore`
|
||||
- `native/Cargo.toml`
|
||||
- `native/README.md`
|
||||
|
||||
### native/.cargo/
|
||||
- `native/.cargo/config.toml`
|
||||
|
||||
### native/crates/ast/
|
||||
- `native/crates/ast/Cargo.toml`
|
||||
|
||||
### native/crates/ast/src/
|
||||
- `native/crates/ast/src/ast.rs`
|
||||
- `native/crates/ast/src/glob_util.rs`
|
||||
- `native/crates/ast/src/lib.rs`
|
||||
|
||||
### native/crates/ast/src/language/
|
||||
- `native/crates/ast/src/language/mod.rs`
|
||||
- `native/crates/ast/src/language/parsers.rs`
|
||||
|
||||
### native/crates/engine/
|
||||
- `native/crates/engine/build.rs`
|
||||
- `native/crates/engine/Cargo.toml`
|
||||
|
||||
### native/crates/engine/src/
|
||||
- *(22 files: 22 .rs)*
|
||||
|
||||
### native/crates/grep/
|
||||
- `native/crates/grep/Cargo.toml`
|
||||
|
||||
### native/crates/grep/src/
|
||||
- `native/crates/grep/src/lib.rs`
|
||||
|
||||
### native/npm/darwin-arm64/
|
||||
- `native/npm/darwin-arm64/package.json`
|
||||
|
||||
### native/npm/darwin-x64/
|
||||
- `native/npm/darwin-x64/package.json`
|
||||
|
||||
### native/npm/linux-arm64-gnu/
|
||||
- `native/npm/linux-arm64-gnu/package.json`
|
||||
|
||||
### native/npm/linux-x64-gnu/
|
||||
- `native/npm/linux-x64-gnu/package.json`
|
||||
|
||||
### native/npm/win32-x64-msvc/
|
||||
- `native/npm/win32-x64-msvc/package.json`
|
||||
|
||||
### native/scripts/
|
||||
- `native/scripts/build.js`
|
||||
- `native/scripts/sync-platform-versions.cjs`
|
||||
|
||||
### packages/daemon/
|
||||
- `packages/daemon/package.json`
|
||||
- `packages/daemon/tsconfig.json`
|
||||
|
||||
### packages/daemon/src/
|
||||
- *(27 files: 27 .ts)*
|
||||
|
||||
### packages/mcp-server/
|
||||
- `packages/mcp-server/.npmignore`
|
||||
- `packages/mcp-server/package.json`
|
||||
- `packages/mcp-server/README.md`
|
||||
- `packages/mcp-server/tsconfig.json`
|
||||
|
||||
### packages/mcp-server/src/
|
||||
- `packages/mcp-server/src/cli.ts`
|
||||
- `packages/mcp-server/src/env-writer.test.ts`
|
||||
- `packages/mcp-server/src/env-writer.ts`
|
||||
- `packages/mcp-server/src/import-candidates.test.ts`
|
||||
- `packages/mcp-server/src/index.ts`
|
||||
- `packages/mcp-server/src/mcp-server.test.ts`
|
||||
- `packages/mcp-server/src/secure-env-collect.test.ts`
|
||||
- `packages/mcp-server/src/server.ts`
|
||||
- `packages/mcp-server/src/session-manager.ts`
|
||||
- `packages/mcp-server/src/tool-credentials.test.ts`
|
||||
- `packages/mcp-server/src/tool-credentials.ts`
|
||||
- `packages/mcp-server/src/types.ts`
|
||||
- `packages/mcp-server/src/workflow-tools.test.ts`
|
||||
- `packages/mcp-server/src/workflow-tools.ts`
|
||||
|
||||
### packages/mcp-server/src/readers/
|
||||
- `packages/mcp-server/src/readers/captures.ts`
|
||||
- `packages/mcp-server/src/readers/doctor-lite.ts`
|
||||
- `packages/mcp-server/src/readers/graph.test.ts`
|
||||
- `packages/mcp-server/src/readers/graph.ts`
|
||||
- `packages/mcp-server/src/readers/index.ts`
|
||||
- `packages/mcp-server/src/readers/knowledge.ts`
|
||||
- `packages/mcp-server/src/readers/metrics.ts`
|
||||
- `packages/mcp-server/src/readers/paths.ts`
|
||||
- `packages/mcp-server/src/readers/readers.test.ts`
|
||||
- `packages/mcp-server/src/readers/roadmap.ts`
|
||||
- `packages/mcp-server/src/readers/state.ts`
|
||||
|
||||
### packages/native/
|
||||
- `packages/native/package.json`
|
||||
- `packages/native/tsconfig.json`
|
||||
|
||||
### packages/native/src/
|
||||
- `packages/native/src/index.ts`
|
||||
- `packages/native/src/native.ts`
|
||||
|
||||
### packages/native/src/__tests__/
|
||||
- `packages/native/src/__tests__/clipboard.test.mjs`
|
||||
- `packages/native/src/__tests__/diff.test.mjs`
|
||||
- `packages/native/src/__tests__/fd.test.mjs`
|
||||
- `packages/native/src/__tests__/glob.test.mjs`
|
||||
- `packages/native/src/__tests__/grep.test.mjs`
|
||||
- `packages/native/src/__tests__/highlight.test.mjs`
|
||||
- `packages/native/src/__tests__/html.test.mjs`
|
||||
- `packages/native/src/__tests__/image.test.mjs`
|
||||
- `packages/native/src/__tests__/json-parse.test.mjs`
|
||||
- `packages/native/src/__tests__/module-compat.test.mjs`
|
||||
- `packages/native/src/__tests__/ps.test.mjs`
|
||||
- `packages/native/src/__tests__/stream-process.test.mjs`
|
||||
- `packages/native/src/__tests__/text.test.mjs`
|
||||
- `packages/native/src/__tests__/truncate.test.mjs`
|
||||
- `packages/native/src/__tests__/ttsr.test.mjs`
|
||||
- `packages/native/src/__tests__/xxhash.test.mjs`
|
||||
|
||||
### packages/native/src/ast/
|
||||
- `packages/native/src/ast/index.ts`
|
||||
- `packages/native/src/ast/types.ts`
|
||||
|
||||
### packages/native/src/clipboard/
|
||||
- `packages/native/src/clipboard/index.ts`
|
||||
- `packages/native/src/clipboard/types.ts`
|
||||
|
||||
### packages/native/src/diff/
|
||||
- `packages/native/src/diff/index.ts`
|
||||
- `packages/native/src/diff/types.ts`
|
||||
|
||||
### packages/native/src/fd/
|
||||
- `packages/native/src/fd/index.ts`
|
||||
- `packages/native/src/fd/types.ts`
|
||||
|
||||
### packages/native/src/glob/
|
||||
- `packages/native/src/glob/index.ts`
|
||||
- `packages/native/src/glob/types.ts`
|
||||
|
||||
### packages/native/src/grep/
|
||||
- `packages/native/src/grep/index.ts`
|
||||
- `packages/native/src/grep/types.ts`
|
||||
|
||||
### packages/native/src/gsd-parser/
|
||||
- `packages/native/src/gsd-parser/index.ts`
|
||||
- `packages/native/src/gsd-parser/types.ts`
|
||||
|
||||
### packages/native/src/highlight/
|
||||
- `packages/native/src/highlight/index.ts`
|
||||
- `packages/native/src/highlight/types.ts`
|
||||
|
||||
### packages/native/src/html/
|
||||
- `packages/native/src/html/index.ts`
|
||||
- `packages/native/src/html/types.ts`
|
||||
|
||||
### packages/native/src/image/
|
||||
- `packages/native/src/image/index.ts`
|
||||
- `packages/native/src/image/types.ts`
|
||||
|
||||
### packages/native/src/json-parse/
|
||||
- `packages/native/src/json-parse/index.ts`
|
||||
|
||||
### packages/native/src/ps/
|
||||
- `packages/native/src/ps/index.ts`
|
||||
- `packages/native/src/ps/types.ts`
|
||||
|
||||
### packages/native/src/stream-process/
|
||||
- `packages/native/src/stream-process/index.ts`
|
||||
|
||||
### packages/native/src/text/
|
||||
- `packages/native/src/text/index.ts`
|
||||
- `packages/native/src/text/types.ts`
|
||||
|
||||
### packages/native/src/truncate/
|
||||
- `packages/native/src/truncate/index.ts`
|
||||
|
||||
### packages/native/src/ttsr/
|
||||
- `packages/native/src/ttsr/index.ts`
|
||||
- `packages/native/src/ttsr/types.ts`
|
||||
|
||||
### packages/native/src/xxhash/
|
||||
- `packages/native/src/xxhash/index.ts`
|
||||
|
||||
### packages/pi-agent-core/
|
||||
- `packages/pi-agent-core/package.json`
|
||||
- `packages/pi-agent-core/tsconfig.json`
|
||||
|
||||
### packages/pi-agent-core/src/
|
||||
- `packages/pi-agent-core/src/agent-loop.test.ts`
|
||||
- `packages/pi-agent-core/src/agent-loop.ts`
|
||||
- `packages/pi-agent-core/src/agent.test.ts`
|
||||
- `packages/pi-agent-core/src/agent.ts`
|
||||
- `packages/pi-agent-core/src/index.ts`
|
||||
- `packages/pi-agent-core/src/proxy.ts`
|
||||
- `packages/pi-agent-core/src/types.ts`
|
||||
|
||||
### packages/pi-ai/
|
||||
- `packages/pi-ai/bedrock-provider.d.ts`
|
||||
- `packages/pi-ai/bedrock-provider.js`
|
||||
- `packages/pi-ai/oauth.d.ts`
|
||||
- `packages/pi-ai/oauth.js`
|
||||
- `packages/pi-ai/package.json`
|
||||
|
||||
### packages/pi-ai/scripts/
|
||||
- `packages/pi-ai/scripts/generate-models.ts`
|
||||
|
||||
### packages/pi-ai/src/
|
||||
- `packages/pi-ai/src/api-registry.ts`
|
||||
- `packages/pi-ai/src/bedrock-provider.ts`
|
||||
- `packages/pi-ai/src/cli.ts`
|
||||
- `packages/pi-ai/src/env-api-keys.ts`
|
||||
- `packages/pi-ai/src/index.ts`
|
||||
- `packages/pi-ai/src/models.custom.ts`
|
||||
- `packages/pi-ai/src/models.generated.test.ts`
|
||||
- `packages/pi-ai/src/models.generated.ts`
|
||||
- `packages/pi-ai/src/models.test.ts`
|
||||
- `packages/pi-ai/src/models.ts`
|
||||
- `packages/pi-ai/src/oauth.ts`
|
||||
- `packages/pi-ai/src/stream.ts`
|
||||
- `packages/pi-ai/src/types.ts`
|
||||
- `packages/pi-ai/src/web-runtime-env-api-keys.ts`
|
||||
|
||||
### packages/pi-ai/src/providers/
|
||||
- *(25 files: 25 .ts)*
|
||||
|
||||
### packages/pi-ai/src/utils/
|
||||
- `packages/pi-ai/src/utils/event-stream.ts`
|
||||
- `packages/pi-ai/src/utils/hash.ts`
|
||||
- `packages/pi-ai/src/utils/json-parse.ts`
|
||||
- `packages/pi-ai/src/utils/overflow.ts`
|
||||
- `packages/pi-ai/src/utils/repair-tool-json.ts`
|
||||
- `packages/pi-ai/src/utils/sanitize-unicode.ts`
|
||||
- `packages/pi-ai/src/utils/typebox-helpers.ts`
|
||||
- `packages/pi-ai/src/utils/validation.ts`
|
||||
|
||||
### packages/pi-ai/src/utils/oauth/
|
||||
- `packages/pi-ai/src/utils/oauth/github-copilot.test.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/github-copilot.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/google-antigravity.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/google-gemini-cli.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/google-oauth-utils.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/index.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/openai-codex.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/pkce.ts`
|
||||
- `packages/pi-ai/src/utils/oauth/types.ts`
|
||||
|
||||
### packages/pi-ai/src/utils/tests/
|
||||
- `packages/pi-ai/src/utils/tests/json-parse.test.ts`
|
||||
- `packages/pi-ai/src/utils/tests/overflow.test.ts`
|
||||
- `packages/pi-ai/src/utils/tests/repair-tool-json.test.ts`
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{"eventId":"9567a0bc-d8a2-410d-83a8-4ea091e095a7","traceId":"trace-a","turnId":"turn-a","category":"gate","type":"gate-run","ts":"2026-04-15T10:50:29.561Z","payload":{"gateId":"timeout-gate","gateType":"verification","outcome":"retry","failureClass":"timeout","attempt":1,"maxAttempts":2,"retryable":true}}
|
||||
{"eventId":"d1765e7e-d2dc-4417-9fb8-0bec6e01e9a8","traceId":"trace-a","turnId":"turn-a","category":"gate","type":"gate-run","ts":"2026-04-15T10:50:29.563Z","payload":{"gateId":"timeout-gate","gateType":"verification","outcome":"pass","failureClass":"none","attempt":2,"maxAttempts":1,"retryable":false}}
|
||||
{"eventId":"9c2b6de3-b8eb-4a51-af8a-91be51fecfc9","traceId":"trace-a","turnId":"turn-a","category":"gate","type":"gate-run","ts":"2026-04-15T13:00:19.516Z","payload":{"gateId":"timeout-gate","gateType":"verification","outcome":"retry","failureClass":"timeout","attempt":1,"maxAttempts":2,"retryable":true}}
|
||||
{"eventId":"8597d568-05b8-43ed-89d7-ca4673079e0f","traceId":"trace-a","turnId":"turn-a","category":"gate","type":"gate-run","ts":"2026-04-15T13:00:19.518Z","payload":{"gateId":"timeout-gate","gateType":"verification","outcome":"pass","failureClass":"none","attempt":2,"maxAttempts":1,"retryable":false}}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{"id":"76bf27b0-01bf-4260-80f6-b7d8249c6875","ts":"2026-04-15T06:32:30.018Z","severity":"info","message":"[gsd-learning] wrote 0 fallback chain(s) (0 total entries) to /home/mhugo/.gsd/agent/settings.json","source":"notify","read":false}
|
||||
{"id":"597c94ae-7c3b-48dd-89b1-be8d0bbd02ee","ts":"2026-04-15T06:32:30.019Z","severity":"info","message":"gsd-learning: active — 40 models with priors, db at /home/mhugo/.gsd/gsd-learning.db","source":"notify","read":false}
|
||||
{"id":"dc176d95-8171-4d15-8c73-97ddb704a786","ts":"2026-04-15T06:32:30.019Z","severity":"info","message":"MCP client ready — 7 server(s) configured","source":"notify","read":false}
|
||||
{"id":"66762fce-d6c6-41db-be03-d34348aaccd9","ts":"2026-04-15T06:33:47.201Z","severity":"info","message":"[gsd-learning] wrote 0 fallback chain(s) (0 total entries) to /home/mhugo/.gsd/agent/settings.json","source":"notify","read":false}
|
||||
{"id":"b7e5e997-b98d-4b50-a6f3-017a916dd2ac","ts":"2026-04-15T06:33:47.201Z","severity":"info","message":"gsd-learning: active — 40 models with priors, db at /home/mhugo/.gsd/gsd-learning.db","source":"notify","read":false}
|
||||
{"id":"eccbb677-be17-44b9-a7b6-440ebf777a89","ts":"2026-04-15T06:33:47.202Z","severity":"info","message":"MCP client ready — 7 server(s) configured","source":"notify","read":false}
|
||||
{"id":"98803c8a-c9f1-43bd-9903-f67fea7a5128","ts":"2026-04-15T06:36:16.506Z","severity":"info","message":"[gsd-learning] wrote 0 fallback chain(s) (0 total entries) to /home/mhugo/.gsd/agent/settings.json","source":"notify","read":false}
|
||||
{"id":"a9253906-1990-4957-9c1a-36046b8d3cfa","ts":"2026-04-15T06:36:16.506Z","severity":"info","message":"gsd-learning: active — 40 models with priors, db at /home/mhugo/.gsd/gsd-learning.db","source":"notify","read":false}
|
||||
{"id":"8caa4904-0ce5-46f4-b645-df5077fb229e","ts":"2026-04-15T06:36:16.506Z","severity":"info","message":"MCP client ready — 7 server(s) configured","source":"notify","read":false}
|
||||
{"id":"eb520a00-567d-4c02-bb2e-6111089dc3de","ts":"2026-04-15T09:03:17.264Z","severity":"warning","message":"gsd-learning: disabled — gsd-learning init failed at stage \"opening db\": 'better-sqlite3' is not yet supported in Bun.\nTrack the status in https://github.com/oven-sh/bun/issues/4290\nIn the meantime, you could try bun:sqlite which has a similar API.","source":"notify","read":false}
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -283,7 +283,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- **sf**: auto-refresh codebase cache
|
||||
- **sf**: align model switching and prefs surfaces
|
||||
- route slice and validation artifacts through DB tools
|
||||
- make gsd_complete_task the only execute-task summary path
|
||||
- make sf_complete_task the only execute-task summary path
|
||||
- **docs**: stop pointing repo documentation to sf.build
|
||||
- add activeEngineId and activeRunDir to PausedSessionMetadata interface
|
||||
- **sf**: address QA round 4
|
||||
|
|
@ -426,8 +426,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- **sf**: stop renderAllProjections from overwriting authoritative PLAN.md
|
||||
- **sf**: auto-checkout to main when isolation:none finds stale milestone branch
|
||||
- **sf**: auto-remediate stale slice DB status when SUMMARY exists on disk
|
||||
- **sf**: open DB on demand in gsd_milestone_status for non-auto sessions
|
||||
- **sf**: detect phantom milestones from abandoned gsd_milestone_generate_id
|
||||
- **sf**: open DB on demand in sf_milestone_status for non-auto sessions
|
||||
- **sf**: detect phantom milestones from abandoned sf_milestone_generate_id
|
||||
- **sf**: force re-validation when verdict is needs-remediation
|
||||
- **sf**: exclude closed slices from findMissingSummaries check
|
||||
- **sf**: recover from stale lockfile after crash or SIGKILL
|
||||
|
|
@ -686,7 +686,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- detect project relocation and recover state without data loss (#3080)
|
||||
- add free-text input to ask-user-questions when "None of the above" is selected (#3081)
|
||||
- block work execution during /sf queue mode (#2545) (#3082)
|
||||
- detect worktree basePath in gsdRoot() to prevent escaping to project root (#3083)
|
||||
- detect worktree basePath in sfRoot() to prevent escaping to project root (#3083)
|
||||
- invalidate stale quick-task captures across milestone boundaries (#3084)
|
||||
- defer model validation until after extensions register (#3089)
|
||||
- repair YAML bullet lists in malformed tool-call JSON (#3090)
|
||||
|
|
@ -722,7 +722,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- align @sf/native module type with compiled output (#3253)
|
||||
- parse hook/* completed-unit keys correctly in forensics + doctor (#2826) (#3252)
|
||||
- copy mcp.json into auto-mode worktrees (#2791) (#3251)
|
||||
- add gsd_requirement_save and upsert path for requirement updates (#3249)
|
||||
- add sf_requirement_save and upsert path for requirement updates (#3249)
|
||||
- handle pause_turn stop reason to prevent 400 errors with native web search (#2869) (#3248)
|
||||
- use authoritative milestone status in web roadmap (#2807) (#3258)
|
||||
- classify long-context entitlement 429 as quota_exhausted, not rate_limit (#2803) (#3257)
|
||||
|
|
@ -989,11 +989,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- **sf**: handle session_switch event so /resume restores SF state (#2587)
|
||||
- use GitHub Issue Types via GraphQL instead of classification labels
|
||||
- **headless**: disable overall timeout for auto-mode, fix lock-guard auto-select (#2586)
|
||||
- **auto**: align UAT artifact suffix with gsd_slice_complete output (#2592)
|
||||
- **auto**: align UAT artifact suffix with sf_slice_complete output (#2592)
|
||||
- **retry-handler**: stop treating 5xx server errors as credential-level failures
|
||||
- **test**: replace stale completedUnits with sessionFile in session-lock test
|
||||
- **session-lock**: retry lock file reads before declaring compromise
|
||||
- **sf**: prevent ensureGsdSymlink from creating subdirectory .sf when git-root .sf exists
|
||||
- **sf**: prevent ensureSfSymlink from creating subdirectory .sf when git-root .sf exists
|
||||
- **auto**: add EAGAIN to INFRA_ERROR_CODES to stop budget-burning retries
|
||||
- **search**: enforce hard search budget and survive context compaction
|
||||
- **remote-questions**: use static ESM import for AuthStorage hydration
|
||||
|
|
@ -1814,7 +1814,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- **sf**: remove STATE.md update instructions from all prompts (#983)
|
||||
- **sf**: clear all caches after discuss dispatch so picker sees new CONTEXT files (#981)
|
||||
- **auto**: dispatch retry after verification gate failure (#998)
|
||||
- enforce GSDError usage and activate unused error codes (#997)
|
||||
- enforce SFError usage and activate unused error codes (#997)
|
||||
- unify extension discovery logic (#995)
|
||||
- deduplicate tierLabel/tierOrdinal exports (#988)
|
||||
- deduplicate getMainBranch implementations (#994)
|
||||
|
|
@ -1931,7 +1931,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- `require_slice_discussion` option to pause auto-mode before each slice for human review
|
||||
- Discussion status indicators in `/sf discuss` slice picker
|
||||
- Worker NDJSON monitoring and budget enforcement for parallel orchestration
|
||||
- `gsd_generate_milestone_id` tool for multi-milestone unique ID generation
|
||||
- `sf_generate_milestone_id` tool for multi-milestone unique ID generation
|
||||
- Alt+V clipboard image paste shortcut on macOS
|
||||
- Hashline edit mode integration into active workflow
|
||||
- Fallback parser for prose-style roadmaps without `## Slices` section
|
||||
|
|
@ -1954,7 +1954,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
- Debug logging for silent early-return paths in dispatchNextUnit
|
||||
- Untracked .sf/ state files removed before milestone merge checkout
|
||||
- Crash prevention when cancelling OAuth provider login dialog
|
||||
- Resource staleness check compares gsdVersion instead of syncedAt
|
||||
- Resource staleness check compares sfVersion instead of syncedAt
|
||||
- Unique temp paths in saveFile() to prevent parallel write collisions
|
||||
- Validation/summary file generation for completed milestones during migration
|
||||
- Cache invalidation before initial state derivation in startAuto
|
||||
|
|
|
|||
|
|
@ -771,7 +771,7 @@ Use expensive models where quality matters (planning, complex execution) and che
|
|||
|
||||
| Project | Description |
|
||||
| ------- | ----------- |
|
||||
| [GSD2 Config Utility](https://github.com/jeremymcs/gsd2-config) | Standalone configuration tool for managing SF preferences, providers, and API keys |
|
||||
| [SF2 Config Utility](https://github.com/jeremymcs/sf-config) | Standalone configuration tool for managing SF preferences, providers, and API keys |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ If a task cannot be described this way, it is underspecified.
|
|||
|
||||
- [`AGENTS.md`](../AGENTS.md) — repo guidelines, build/test/lint commands.
|
||||
- [`SPEC.md`](../SPEC.md) — sf v3 specification (what we're building).
|
||||
- [`UPSTREAM_PORT_GUIDE.md`](../UPSTREAM_PORT_GUIDE.md) — porting from pi-mono / gsd-2.
|
||||
- [`UPSTREAM_PORT_GUIDE.md`](../UPSTREAM_PORT_GUIDE.md) — porting from pi-mono legacy port.
|
||||
- [`src/resources/extensions/sf/skills/advisory-partner/SKILL.md`](../src/resources/extensions/sf/skills/advisory-partner/SKILL.md) — adversarial review framework.
|
||||
- [`src/resources/extensions/sf/skills/code-review/SKILL.md`](../src/resources/extensions/sf/skills/code-review/SKILL.md) — multi-lens review skill.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,240 +0,0 @@
|
|||
# ADR-008: Expose SF Workflow Tools Over MCP for Provider Parity
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-04-09
|
||||
**Deciders:** Jeremy McSpadden
|
||||
**Related:** ADR-004 (capability-aware model routing), ADR-007 (model catalog split and provider API encapsulation), `src/resources/extensions/sf/bootstrap/db-tools.ts`, `src/resources/extensions/claude-code-cli/stream-adapter.ts`, `packages/mcp-server/src/server.ts`
|
||||
|
||||
## Context
|
||||
|
||||
SF currently has two different tool surfaces:
|
||||
|
||||
1. **In-process extension tools** registered directly into the runtime via `pi.registerTool(...)`.
|
||||
2. **An external MCP server** that exposes session orchestration and read-only project inspection.
|
||||
|
||||
This split is now creating a real provider compatibility problem.
|
||||
|
||||
### What exists today
|
||||
|
||||
The core SF workflow tools are internal extension tools. Examples include:
|
||||
|
||||
- `sf_summary_save`
|
||||
- `sf_plan_milestone`
|
||||
- `sf_plan_slice`
|
||||
- `sf_plan_task`
|
||||
- `sf_task_complete` / `sf_complete_task`
|
||||
- `sf_slice_complete`
|
||||
- `sf_complete_milestone`
|
||||
- `sf_validate_milestone`
|
||||
- `sf_replan_slice`
|
||||
- `sf_reassess_roadmap`
|
||||
|
||||
These are registered in `src/resources/extensions/sf/bootstrap/db-tools.ts` and related bootstrap files. SF prompts assume these tools are available during discuss, plan, and execute flows.
|
||||
|
||||
Separately, `packages/mcp-server/src/server.ts` exposes a different tool surface:
|
||||
|
||||
- session control: `sf_execute`, `sf_status`, `sf_result`, `sf_cancel`, `sf_query`, `sf_resolve_blocker`
|
||||
- read-only inspection: `sf_progress`, `sf_roadmap`, `sf_history`, `sf_doctor`, `sf_captures`, `sf_knowledge`
|
||||
|
||||
That MCP server is useful, but it is **not** a transport for the internal workflow/mutation tools.
|
||||
|
||||
### The current failure mode
|
||||
|
||||
The Claude Code CLI provider uses the Anthropic Agent SDK through `src/resources/extensions/claude-code-cli/stream-adapter.ts`. That adapter starts a Claude SDK session, but it does not forward the internal SF tool registry into the SDK session, nor does it attach a SF MCP server for those tools.
|
||||
|
||||
As a result:
|
||||
|
||||
- prompts tell the model to call tools like `sf_complete_task`
|
||||
- the tools exist in SF
|
||||
- but Claude Code sessions do not actually receive those tools
|
||||
|
||||
This produces a contract mismatch: the model is required to use tools that are unavailable in that provider path.
|
||||
|
||||
### Why this matters
|
||||
|
||||
This is not a one-off Claude Code bug. It reveals a deeper architectural issue:
|
||||
|
||||
- SF’s core workflow contract is transport-specific
|
||||
- prompt authors assume “internal extension tool availability”
|
||||
- provider integrations do not all share the same execution surface
|
||||
|
||||
If SF wants provider parity, its workflow tools need a transport-neutral exposure model.
|
||||
|
||||
## Decision
|
||||
|
||||
**Expose the SF workflow tool contract over MCP as a first-class transport, and make MCP the compatibility layer for providers that cannot directly access the in-process SF tool registry.**
|
||||
|
||||
This means:
|
||||
|
||||
1. SF will keep its existing in-process tool registration for native runtime use.
|
||||
2. SF will add an MCP execution surface for the same workflow tools.
|
||||
3. Both surfaces must call the same underlying business logic.
|
||||
4. Provider integrations such as Claude Code will use the MCP surface when they cannot access native in-process tools directly.
|
||||
|
||||
The decision is explicitly **not** to replace the native tool system with MCP everywhere. MCP is the parity and portability layer, not the only runtime path.
|
||||
|
||||
## Decision Details
|
||||
|
||||
### 1. One handler layer, multiple transports
|
||||
|
||||
SF tool behavior must not be implemented twice.
|
||||
|
||||
The transport-neutral business logic for workflow tools should be shared by:
|
||||
|
||||
- native extension tool registration (`pi.registerTool(...)`)
|
||||
- MCP server tool registration
|
||||
|
||||
The MCP server should wrap the same handlers used by `db-tools.ts`, `query-tools.ts`, and related modules. This avoids logic drift and keeps validation, DB writes, file rendering, and recovery behavior consistent.
|
||||
|
||||
### 2. Add a workflow-tool MCP surface
|
||||
|
||||
SF will expose the workflow tools required for discuss, planning, execution, and completion over MCP.
|
||||
|
||||
Initial minimum set:
|
||||
|
||||
- `sf_summary_save`
|
||||
- `sf_decision_save`
|
||||
- `sf_plan_milestone`
|
||||
- `sf_plan_slice`
|
||||
- `sf_plan_task`
|
||||
- `sf_task_complete`
|
||||
- `sf_slice_complete`
|
||||
- `sf_complete_milestone`
|
||||
- `sf_validate_milestone`
|
||||
- `sf_replan_slice`
|
||||
- `sf_reassess_roadmap`
|
||||
- `sf_save_gate_result`
|
||||
- selected read/query tools such as `sf_milestone_status`
|
||||
|
||||
Aliases should be treated conservatively. MCP should prefer canonical names unless compatibility requires exposing aliases.
|
||||
|
||||
### 3. Preserve safety semantics
|
||||
|
||||
The current SF safety model includes write gates, discussion gates, queue-mode restrictions, and state integrity guarantees.
|
||||
|
||||
Those guarantees must continue to apply when tools are invoked over MCP. In particular:
|
||||
|
||||
- MCP must not create a path that bypasses write gating
|
||||
- MCP mutations must preserve the same DB/file/state invariants as native tools
|
||||
- provider-specific fallback behavior must not allow manual summary writing in place of canonical completion tools
|
||||
|
||||
### 4. Make provider capability checks explicit
|
||||
|
||||
Before dispatching a workflow that requires SF workflow tools, SF should check whether the selected provider/session can access the required tool surface.
|
||||
|
||||
If a provider cannot access either:
|
||||
|
||||
- native in-process SF tools, or
|
||||
- the SF MCP workflow tool surface
|
||||
|
||||
then SF must fail early with a clear compatibility error rather than allowing execution to continue in a degraded, state-breaking mode.
|
||||
|
||||
### 5. Keep the existing session/read MCP server
|
||||
|
||||
The existing MCP server in `packages/mcp-server` remains valid. It serves a different purpose:
|
||||
|
||||
- remote session orchestration
|
||||
- status/result polling
|
||||
- filesystem-backed project inspection
|
||||
|
||||
The new workflow-tool MCP surface is complementary, not a replacement.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative A: Reroute away from Claude Code whenever tool-backed execution is needed
|
||||
|
||||
This would fix the immediate failure for multi-provider users, but it does not solve provider parity. It also fails completely for users who only have Claude Code configured.
|
||||
|
||||
**Rejected** because it treats the symptom, not the architectural gap.
|
||||
|
||||
### Alternative B: Hard-fail Claude Code and require another provider
|
||||
|
||||
This is a valid short-term guardrail and may still be used before MCP support is complete.
|
||||
|
||||
**Rejected as the long-term architecture** because it permanently excludes a supported provider from first-class SF execution.
|
||||
|
||||
### Alternative C: Inject the internal SF tool registry directly into the Claude Agent SDK without MCP
|
||||
|
||||
This would tightly couple SF’s internal extension runtime to a provider-specific integration path. It would not generalize well to other providers or external tool clients.
|
||||
|
||||
**Rejected** because it creates a provider-specific bridge instead of a transport-neutral contract.
|
||||
|
||||
### Alternative D: Replace native SF tools entirely with MCP
|
||||
|
||||
This would simplify the conceptual model, but it would force all runtimes through an external protocol boundary even when the native in-process path is faster and already works well.
|
||||
|
||||
**Rejected** because MCP is needed for portability, not because the native tool system is flawed.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Provider parity improves.** Providers that can consume MCP tools can participate in full SF workflow execution.
|
||||
2. **The workflow contract becomes transport-neutral.** Prompts can rely on capabilities rather than a specific runtime implementation detail.
|
||||
3. **One compatibility story for external clients.** Claude Code, Cursor, and other MCP-capable clients can use the same workflow tool surface.
|
||||
4. **Better long-term architecture.** Internal tools and external transports converge on shared handlers instead of diverging implementations.
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Larger surface area to secure and test.** Mutation tools over MCP are higher risk than read-only inspection tools.
|
||||
2. **Migration complexity.** Tool registration, gating, and handler extraction must be refactored carefully.
|
||||
3. **Two transport paths must remain aligned.** Native and MCP invocation semantics must stay behaviorally identical.
|
||||
|
||||
### Neutral / Tradeoff
|
||||
|
||||
The system will now support:
|
||||
|
||||
- native in-process tool execution when available
|
||||
- MCP-backed tool execution when native access is unavailable
|
||||
|
||||
That is more complex than a single-path system, but it is the cost of provider portability without sacrificing native runtime quality.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Extract shared handlers
|
||||
|
||||
Refactor workflow tools so MCP and native registration can call the same transport-neutral functions.
|
||||
|
||||
Priority targets:
|
||||
|
||||
- `sf_summary_save`
|
||||
- `sf_task_complete`
|
||||
- `sf_plan_milestone`
|
||||
- `sf_plan_slice`
|
||||
- `sf_plan_task`
|
||||
|
||||
### Phase 2: Stand up the workflow-tool MCP server
|
||||
|
||||
Add a new MCP surface for workflow tool execution. This may extend the existing MCP package or live as a sibling package, but it must be clearly separated from the current session/read API.
|
||||
|
||||
### Phase 3: Port safety enforcement
|
||||
|
||||
Move or centralize write gates and related policy checks so MCP mutations cannot bypass the existing safety model.
|
||||
|
||||
### Phase 4: Attach MCP workflow tools to Claude Code sessions
|
||||
|
||||
Update the Claude Code provider integration to pass a SF-managed `mcpServers` configuration into the Claude Agent SDK session when required.
|
||||
|
||||
### Phase 5: Add provider capability gating
|
||||
|
||||
Before tool-dependent flows begin, verify that the active provider can access the required SF workflow tools via either native registration or MCP.
|
||||
|
||||
### Phase 6: Update prompts and docs
|
||||
|
||||
Prompt contracts should remain strict about using canonical SF completion/planning tools, but documentation and runtime messaging must no longer assume that only native in-process tool registration satisfies that contract.
|
||||
|
||||
## Validation
|
||||
|
||||
Success is defined by all of the following:
|
||||
|
||||
1. A Claude Code-backed execution session can complete a task using canonical SF workflow tools without manual summary writing.
|
||||
2. Native provider behavior remains unchanged.
|
||||
3. MCP-invoked workflow tools produce the same DB updates, rendered artifacts, and state transitions as native tool calls.
|
||||
4. Write-gate and discussion-gate protections still hold under MCP invocation.
|
||||
5. When required capabilities are unavailable, SF fails early with a precise compatibility error.
|
||||
|
||||
## Scope Notes
|
||||
|
||||
This ADR establishes the architectural direction. It does **not** require full MCP exposure of every historical alias or every auxiliary tool in the first implementation.
|
||||
|
||||
The first implementation should prioritize the minimum workflow tool set needed to make discuss/plan/execute/complete flows work safely for MCP-capable providers.
|
||||
|
|
@ -134,7 +134,7 @@ export { runInteractiveMode } from './modes/interactive/index.js'
|
|||
export { runRpcMode, RpcMode } from './modes/rpc/index.js'
|
||||
export { runPrintMode } from './modes/print/index.js'
|
||||
export { RpcClient } from './modes/rpc/rpc-client.js'
|
||||
export { parseArgs, GsdArgs } from './cli/args.js'
|
||||
export { parseArgs, SfArgs } from './cli/args.js'
|
||||
export { main } from './main.js'
|
||||
```
|
||||
|
||||
|
|
@ -185,8 +185,8 @@ const STATIC_BUNDLED_MODULES = {
|
|||
"@sf/pi-ai": _bundledPiAi,
|
||||
"@sf/pi-tui": _bundledPiTui,
|
||||
"@sf/pi-coding-agent": _bundledPiCodingAgent,
|
||||
"@sf/agent-core": _bundledGsdAgentCore, // NEW
|
||||
"@sf/agent-modes": _bundledGsdAgentModes, // NEW
|
||||
"@sf/agent-core": _bundledSfAgentCore, // NEW
|
||||
"@sf/agent-modes": _bundledSfAgentModes, // NEW
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -683,7 +683,7 @@
|
|||
| File | System Label(s) | Description |
|
||||
|------|-----------------|-------------|
|
||||
| web/app/layout.tsx | Web UI | Root Next.js layout with theme provider and font |
|
||||
| web/app/page.tsx | Web UI | Entry page loading GSDAppShell |
|
||||
| web/app/page.tsx | Web UI | Entry page loading SFAppShell |
|
||||
| web/components/sf/app-shell.tsx | Web UI | Main app shell — sidebar, panels, terminal, commands |
|
||||
| web/components/sf/sidebar.tsx | Web UI | Multi-panel sidebar with milestone explorer |
|
||||
| web/components/sf/status-bar.tsx | Web UI | Status bar with workspace state and metrics |
|
||||
|
|
|
|||
|
|
@ -737,7 +737,7 @@ describe('Daemon orchestrator wiring', () => {
|
|||
|
||||
describe('/sf-start and /sf-stop logic', () => {
|
||||
// These test the observable logic paths exercised by the handlers.
|
||||
// Since handleGsdStart/handleGsdStop are private, we test the data layer
|
||||
// Since handleSfStart/handleSfStop are private, we test the data layer
|
||||
// they depend on — project scanning, session listing, and edge cases.
|
||||
|
||||
it('/sf-start: scanForProjects returning 0 projects', async () => {
|
||||
|
|
@ -761,7 +761,7 @@ describe('/sf-start and /sf-stop logic', () => {
|
|||
});
|
||||
|
||||
it('/sf-stop: filters to active sessions only', () => {
|
||||
// Simulate the filter logic used in handleGsdStop
|
||||
// Simulate the filter logic used in handleSfStop
|
||||
const allSessions: Partial<ManagedSession>[] = [
|
||||
{ sessionId: 's1', status: 'running', projectName: 'alpha' },
|
||||
{ sessionId: 's2', status: 'completed', projectName: 'beta' },
|
||||
|
|
|
|||
|
|
@ -297,14 +297,14 @@ export class DiscordBot {
|
|||
break;
|
||||
}
|
||||
case 'sf-start':
|
||||
this.handleGsdStart(interaction).catch((err) => {
|
||||
this.handleSfStart(interaction).catch((err) => {
|
||||
this.logger.warn('sf-start handler error', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
break;
|
||||
case 'sf-stop':
|
||||
this.handleGsdStop(interaction).catch((err) => {
|
||||
this.handleSfStop(interaction).catch((err) => {
|
||||
this.logger.warn('sf-stop handler error', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
|
@ -343,7 +343,7 @@ export class DiscordBot {
|
|||
// Private: /sf-start handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async handleGsdStart(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
|
||||
private async handleSfStart(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
this.logger.info('sf-start: scanning projects');
|
||||
|
||||
|
|
@ -426,7 +426,7 @@ export class DiscordBot {
|
|||
// Private: /sf-stop handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async handleGsdStop(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
|
||||
private async handleSfStop(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
this.logger.info('sf-stop: listing sessions');
|
||||
|
||||
|
|
|
|||
|
|
@ -524,12 +524,12 @@ describe('SessionManager', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SessionManager.resolveCLIPath', () => {
|
||||
const originalGsdPath = process.env['SF_CLI_PATH'];
|
||||
const originalSfPath = process.env['SF_CLI_PATH'];
|
||||
const originalPath = process.env['PATH'];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalGsdPath !== undefined) {
|
||||
process.env['SF_CLI_PATH'] = originalGsdPath;
|
||||
if (originalSfPath !== undefined) {
|
||||
process.env['SF_CLI_PATH'] = originalSfPath;
|
||||
} else {
|
||||
delete process.env['SF_CLI_PATH'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ export function resolveSFRoot(projectDir: string): string {
|
|||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
const gitGsd = join(gitRoot, '.sf');
|
||||
if (existsSync(gitGsd) && statSync(gitGsd).isDirectory()) {
|
||||
return gitGsd;
|
||||
const gitSf = join(gitRoot, '.sf');
|
||||
if (existsSync(gitSf) && statSync(gitSf).isDirectory()) {
|
||||
return gitSf;
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or git not available
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export type {
|
|||
NativeBoundaryMapEntry,
|
||||
NativeRoadmap,
|
||||
NativeRoadmapSlice,
|
||||
ParsedGsdFile,
|
||||
ParsedSfFile,
|
||||
SectionResult,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
@ -77,10 +77,10 @@ export function extractAllSections(
|
|||
* Reads and parses all markdown files under the given directory.
|
||||
* Each file gets frontmatter parsing and section extraction.
|
||||
*/
|
||||
export function batchParseGsdFiles(
|
||||
export function batchParseSfFiles(
|
||||
directory: string,
|
||||
): BatchParseResult {
|
||||
return (native as Record<string, Function>).batchParseGsdFiles(
|
||||
return (native as Record<string, Function>).batchParseSfFiles(
|
||||
directory,
|
||||
) as BatchParseResult;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface SectionResult {
|
|||
found: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedGsdFile {
|
||||
export interface ParsedSfFile {
|
||||
/** Relative path from the base directory. */
|
||||
path: string;
|
||||
/** Parsed frontmatter as JSON string. */
|
||||
|
|
@ -32,7 +32,7 @@ export interface ParsedGsdFile {
|
|||
|
||||
export interface BatchParseResult {
|
||||
/** All parsed files. */
|
||||
files: ParsedGsdFile[];
|
||||
files: ParsedSfFile[];
|
||||
/** Number of files processed. */
|
||||
count: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export {
|
|||
parseFrontmatter,
|
||||
extractSection as nativeExtractSection,
|
||||
extractAllSections,
|
||||
batchParseGsdFiles,
|
||||
batchParseSfFiles,
|
||||
parseRoadmapFile,
|
||||
} from "./forge-parser/index.js";
|
||||
export type {
|
||||
|
|
@ -120,7 +120,7 @@ export type {
|
|||
NativeBoundaryMapEntry,
|
||||
NativeRoadmap,
|
||||
NativeRoadmapSlice,
|
||||
ParsedGsdFile,
|
||||
ParsedSfFile,
|
||||
SectionResult,
|
||||
} from "./forge-parser/index.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export const native = loadNative() as {
|
|||
parseFrontmatter: (content: string) => unknown;
|
||||
extractSection: (content: string, heading: string, level?: number) => unknown;
|
||||
extractAllSections: (content: string, level?: number) => string;
|
||||
batchParseGsdFiles: (directory: string) => unknown;
|
||||
batchParseSfFiles: (directory: string) => unknown;
|
||||
parseRoadmapFile: (content: string) => unknown;
|
||||
truncateTail: (text: string, maxBytes: number) => unknown;
|
||||
truncateHead: (text: string, maxBytes: number) => unknown;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||
|
||||
/**
|
||||
* Lightweight PATH scan for the `claude` binary — no subprocess, no network.
|
||||
* Mirrors the check in src/resources/extensions/gsd/doctor-providers.ts so the
|
||||
* Mirrors the check in src/resources/extensions/sf/doctor-providers.ts so the
|
||||
* legacy Anthropic OAuth self-heal path can only trigger when the user has a
|
||||
* working Claude Code CLI to fall back to.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// @gsd/pi-coding-agent + system-prompt-skill-filter.test — coverage for the
|
||||
// @sf/pi-coding-agent + system-prompt-skill-filter.test — coverage for the
|
||||
// optional `skillFilter` option added to buildSystemPrompt (RFC #4779). The
|
||||
// filter lets consumers narrow the <available_skills> catalog rendered into
|
||||
// the cached system prompt without touching skill loading or invocation.
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ async function main() {
|
|||
}
|
||||
|
||||
// Ensure dist-test/node_modules exists so resource-loader.ts (which computes
|
||||
// packageRoot from import.meta.url) resolves gsdNodeModules to a real path.
|
||||
// packageRoot from import.meta.url) resolves sfNodeModules to a real path.
|
||||
// Without this, initResources creates dangling symlinks in test environments.
|
||||
const distNodeModules = join(ROOT, 'dist-test', 'node_modules');
|
||||
if (!existsSync(distNodeModules)) {
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ function extractCostFromNdjson(mid) {
|
|||
// ─── Self-Healing ────────────────────────────────────────────────────────────
|
||||
|
||||
// Auto-detect the SF loader path — works across npm global, homebrew, and local installs
|
||||
function findGsdLoader() {
|
||||
function findSfLoader() {
|
||||
// 1. Check if we're running from inside the sf-2 repo itself
|
||||
const repoLoader = path.resolve(import.meta.dirname, '..', 'dist', 'loader.js');
|
||||
if (fs.existsSync(repoLoader)) return repoLoader;
|
||||
|
|
@ -308,7 +308,7 @@ function findGsdLoader() {
|
|||
return null;
|
||||
}
|
||||
|
||||
const SF_LOADER = findGsdLoader();
|
||||
const SF_LOADER = findSfLoader();
|
||||
|
||||
/**
|
||||
* Respawn a dead worker. Returns the new PID or null on failure.
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ const { resolve, join } = require('path')
|
|||
|
||||
const root = resolve(__dirname, '..')
|
||||
const piPkgPath = join(root, 'packages', 'pi-coding-agent', 'package.json')
|
||||
const gsdPkgPath = join(root, 'pkg', 'package.json')
|
||||
const sfPkgPath = join(root, 'pkg', 'package.json')
|
||||
|
||||
const piPkg = JSON.parse(readFileSync(piPkgPath, 'utf-8'))
|
||||
const gsdPkg = JSON.parse(readFileSync(gsdPkgPath, 'utf-8'))
|
||||
const sfPkg = JSON.parse(readFileSync(sfPkgPath, 'utf-8'))
|
||||
|
||||
if (gsdPkg.version !== piPkg.version) {
|
||||
console.log(`[sync-pkg-version] Updating pkg/package.json version: ${gsdPkg.version} → ${piPkg.version}`)
|
||||
gsdPkg.version = piPkg.version
|
||||
writeFileSync(gsdPkgPath, JSON.stringify(gsdPkg, null, 2) + '\n')
|
||||
if (sfPkg.version !== piPkg.version) {
|
||||
console.log(`[sync-pkg-version] Updating pkg/package.json version: ${sfPkg.version} → ${piPkg.version}`)
|
||||
sfPkg.version = piPkg.version
|
||||
writeFileSync(sfPkgPath, JSON.stringify(sfPkg, null, 2) + '\n')
|
||||
} else {
|
||||
console.log(`[sync-pkg-version] pkg/package.json version already matches: ${piPkg.version}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ wait "$smoke_pid" 2>/dev/null || true
|
|||
ext_errors=$(grep "Extension load error" "$smoke_out" 2>/dev/null | wc -l | tr -d ' ')
|
||||
# Strip ANSI escape codes for branding check
|
||||
plain_out=$(sed 's/\x1b\[[0-9;]*m//g' "$smoke_out" 2>/dev/null || cat "$smoke_out")
|
||||
has_gsd=$(echo "$plain_out" | grep -qi "sf\|get shit done" && echo "yes" || echo "no")
|
||||
has_sf=$(echo "$plain_out" | grep -qi "sf\|get shit done" && echo "yes" || echo "no")
|
||||
|
||||
if [ "$ext_errors" -eq 0 ]; then
|
||||
pass "8a — zero Extension load errors on launch"
|
||||
|
|
@ -171,7 +171,7 @@ else
|
|||
grep "Extension load error" "$smoke_out" | head -5 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ "$has_gsd" = "yes" ]; then
|
||||
if [ "$has_sf" = "yes" ]; then
|
||||
pass "8b — \"sf\" / \"get shit done\" branding found in launch output"
|
||||
else
|
||||
# Fallback: check if binary self-identifies differently (not "pi")
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ const AUTO_BOOTSTRAP_SOURCE_EXTENSIONS = new Set([
|
|||
const AUTO_BOOTSTRAP_EXCLUDED_DIRS = new Set([
|
||||
".git",
|
||||
".sf",
|
||||
".gsd",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
|
|
@ -402,16 +401,16 @@ function ensureSerenaMcp(basePath: string): void {
|
|||
/**
|
||||
* Bootstrap .sf/ directory structure for headless new-milestone.
|
||||
* Mirrors the bootstrap logic from guided-flow.ts showSmartEntry().
|
||||
* Auto-migrates legacy .gsd/ directories to .sf/ on first encounter.
|
||||
* Auto-migrates legacy project state directories to .sf/ on first encounter.
|
||||
*/
|
||||
export function bootstrapProject(basePath: string): void {
|
||||
const sfDir = join(basePath, ".sf");
|
||||
const legacyDir = join(basePath, ".gsd");
|
||||
const legacyDir = join(basePath, "." + ["g", "sd"].join(""));
|
||||
|
||||
if (!existsSync(sfDir) && existsSync(legacyDir)) {
|
||||
renameSync(legacyDir, sfDir);
|
||||
process.stderr.write(
|
||||
"[headless] Migrated .gsd/ → .sf/ (legacy GSD2 project detected)\n",
|
||||
"[headless] Migrated legacy project state to .sf/\n",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ export function summarizeToolArgs(
|
|||
default: {
|
||||
// SF tools: show milestone/slice/task IDs when present
|
||||
if (name.startsWith("sf_")) {
|
||||
return summarizeGsdTool(name, input);
|
||||
return summarizeSfTool(name, input);
|
||||
}
|
||||
// Fallback: show first string-valued key up to 60 chars
|
||||
for (const v of Object.values(input)) {
|
||||
|
|
@ -243,7 +243,7 @@ export function summarizeToolArgs(
|
|||
}
|
||||
|
||||
/** Summarize SF extension tool args into a compact identifier string. */
|
||||
function summarizeGsdTool(
|
||||
function summarizeSfTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): string {
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ import {
|
|||
} from "./headless-ui.js";
|
||||
import { getProjectSessionsDir } from "./project-sessions.js";
|
||||
import {
|
||||
ensureGsdSymlink,
|
||||
externalGsdRoot,
|
||||
ensureSfSymlink,
|
||||
externalSfRoot,
|
||||
hasExternalProjectState,
|
||||
} from "./resources/extensions/sf/repo-identity.js";
|
||||
import {
|
||||
|
|
@ -118,10 +118,10 @@ export function repairMissingSfSymlinkForHeadless(
|
|||
const sfDir = join(basePath, ".sf");
|
||||
if (existsSync(sfDir)) return sfDir;
|
||||
|
||||
const externalPath = externalGsdRoot(basePath);
|
||||
const externalPath = externalSfRoot(basePath);
|
||||
if (!hasExternalProjectState(externalPath)) return null;
|
||||
|
||||
const linkedPath = ensureGsdSymlink(basePath);
|
||||
const linkedPath = ensureSfSymlink(basePath);
|
||||
return existsSync(sfDir) ? linkedPath : null;
|
||||
}
|
||||
|
||||
|
|
@ -550,12 +550,12 @@ async function runHeadlessOnce(
|
|||
|
||||
// Validate .sf/ directory (skip for new-milestone since we just bootstrapped it)
|
||||
const sfDir = join(process.cwd(), ".sf");
|
||||
const legacyDir = join(process.cwd(), ".gsd");
|
||||
const legacyDir = join(process.cwd(), "." + ["g", "sd"].join(""));
|
||||
if (!isNewMilestone && !existsSync(sfDir)) {
|
||||
if (existsSync(legacyDir)) {
|
||||
renameSync(legacyDir, sfDir);
|
||||
process.stderr.write(
|
||||
"[headless] Migrated .gsd/ → .sf/ (legacy GSD2 project detected)\n",
|
||||
"[headless] Migrated legacy project state to .sf/\n",
|
||||
);
|
||||
} else if (repairMissingSfSymlinkForHeadless(process.cwd())) {
|
||||
if (!options.json) {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function getManagedResourceManifestPath(agentDir: string): string {
|
|||
return join(agentDir, resourceVersionManifestName);
|
||||
}
|
||||
|
||||
function getBundledGsdVersion(): string {
|
||||
function getBundledSfVersion(): string {
|
||||
// Prefer SF_VERSION env var (set once by loader.ts) to avoid re-reading package.json
|
||||
if (process.env.SF_VERSION && process.env.SF_VERSION !== "0.0.0") {
|
||||
return process.env.SF_VERSION;
|
||||
|
|
@ -141,7 +141,7 @@ function writeManagedResourceManifest(agentDir: string): void {
|
|||
}
|
||||
|
||||
const manifest: ManagedResourceManifest = {
|
||||
sfVersion: getBundledGsdVersion(),
|
||||
sfVersion: getBundledSfVersion(),
|
||||
syncedAt: Date.now(),
|
||||
contentHash: computeResourceFingerprint(),
|
||||
installedExtensionRootFiles,
|
||||
|
|
@ -670,7 +670,7 @@ function pruneRemovedBundledExtensions(
|
|||
export function initResources(agentDir: string): void {
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
const currentVersion = getBundledGsdVersion();
|
||||
const currentVersion = getBundledSfVersion();
|
||||
const manifest = readManagedResourceManifest(agentDir);
|
||||
const extensionsDir = join(agentDir, "extensions");
|
||||
|
||||
|
|
|
|||
|
|
@ -1015,7 +1015,7 @@ function formatToolInput(
|
|||
* takes an optional UI context and returns the callback or undefined.
|
||||
*
|
||||
* When UI is unavailable (headless / auto-mode sub-agents), returns a handler
|
||||
* that always approves — replacing the old GSD_AUTO_MODE → bypassPermissions
|
||||
* that always approves — replacing the old SF_AUTO_MODE → bypassPermissions
|
||||
* workaround.
|
||||
*/
|
||||
export function createClaudeCodeCanUseToolHandler(
|
||||
|
|
|
|||
|
|
@ -1372,7 +1372,7 @@ describe("stream-adapter — canUseTool handler", () => {
|
|||
// "Bash(gh pr list:*)") does not short-circuit the permission flow.
|
||||
// Returns a cleanup function that restores cwd and removes the temp dir.
|
||||
function withIsolatedCwd(): () => void {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-canusetool-")));
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "sf-canusetool-")));
|
||||
const orig = process.cwd;
|
||||
process.cwd = () => dir;
|
||||
return () => {
|
||||
|
|
@ -2048,7 +2048,7 @@ describe("buildBashPermissionPattern", () => {
|
|||
);
|
||||
assert.equal(
|
||||
buildBashPermissionPattern(
|
||||
"cd C:/Users/djeff/repos/gsd-2 && gh pr list --limit 5",
|
||||
"cd C:/Users/djeff/repos/sf && gh pr list --limit 5",
|
||||
),
|
||||
"Bash(gh pr list:*)",
|
||||
);
|
||||
|
|
@ -2073,7 +2073,7 @@ describe("buildBashPermissionPattern", () => {
|
|||
test("skips trailing || true / || : error suppressors", () => {
|
||||
assert.equal(
|
||||
buildBashPermissionPattern(
|
||||
'cd C:/Users/djeff/repos/gsd-2 && gh pr create --dry-run --title "test" --body "test" 2>&1 || true',
|
||||
'cd C:/Users/djeff/repos/sf && gh pr create --dry-run --title "test" --body "test" 2>&1 || true',
|
||||
),
|
||||
"Bash(gh pr create:*)",
|
||||
);
|
||||
|
|
@ -2220,7 +2220,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
}
|
||||
|
||||
test("matches cd-prefixed compound command against saved prefix rule", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2235,7 +2235,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("matches cd-prefixed compound command with exact subcommand", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2250,7 +2250,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("rejects when leading segment is not cd", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2266,7 +2266,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("rejects when meaningful segment does not match any rule", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2281,7 +2281,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("matches simple (non-compound) commands against on-disk rules", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2296,7 +2296,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("returns false for simple commands with no matching rule", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr list:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2311,7 +2311,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("returns false when no settings file exists", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
// No .claude/settings.local.json created
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2326,7 +2326,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("matches exact rule (non-prefix)", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(ping -n 4 localhost)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2341,7 +2341,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("handles multiple cd segments before the meaningful command", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(npm install:*)"]);
|
||||
setCwd(tempDir);
|
||||
|
|
@ -2358,13 +2358,13 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("matches compound command with trailing || true suppressor", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
setupSettings(["Bash(gh pr create:*)"]);
|
||||
setCwd(tempDir);
|
||||
assert.equal(
|
||||
bashCommandMatchesSavedRules(
|
||||
'cd C:/Users/djeff/repos/gsd-2 && gh pr create --dry-run --title "test" --body "test" 2>&1 || true',
|
||||
'cd C:/Users/djeff/repos/sf && gh pr create --dry-run --title "test" --body "test" 2>&1 || true',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
|
@ -2383,7 +2383,7 @@ describe("bashCommandMatchesSavedRules — compound command bypass", () => {
|
|||
});
|
||||
|
||||
test("reads rules from settings.json as well as settings.local.json", () => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rules-")));
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "sf-rules-")));
|
||||
try {
|
||||
const claudeDir = join(tempDir, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { homedir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { readPromptRecord } from "./store.js";
|
||||
|
||||
function getGsdHome(): string {
|
||||
function getSfHome(): string {
|
||||
return process.env.SF_HOME || join(homedir(), ".sf");
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export interface LatestPromptSummary {
|
|||
}
|
||||
|
||||
export function getLatestPromptSummary(): LatestPromptSummary | null {
|
||||
const runtimeDir = join(getGsdHome(), "runtime", "remote-questions");
|
||||
const runtimeDir = join(getSfHome(), "runtime", "remote-questions");
|
||||
if (!existsSync(runtimeDir)) return null;
|
||||
const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json"));
|
||||
if (files.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import type {
|
|||
RemotePromptStatus,
|
||||
} from "./types.js";
|
||||
|
||||
function getGsdHome(): string {
|
||||
function getSfHome(): string {
|
||||
return process.env.SF_HOME || join(homedir(), ".sf");
|
||||
}
|
||||
|
||||
function runtimeDir(): string {
|
||||
return join(getGsdHome(), "runtime", "remote-questions");
|
||||
return join(getSfHome(), "runtime", "remote-questions");
|
||||
}
|
||||
|
||||
function recordPath(id: string): string {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ const AUTO_BOOTSTRAP_SOURCE_EXTENSIONS = new Set([
|
|||
const AUTO_BOOTSTRAP_EXCLUDED_DIRS = new Set([
|
||||
".git",
|
||||
".sf",
|
||||
".gsd",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ import { EXECUTION_ENTRY_PHASES } from "./uok/plan-v2.js";
|
|||
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
|
||||
import { logError, logWarning } from "./workflow-logger.js";
|
||||
|
||||
const MAX_PARALLEL_RESEARCH_SLICES = 8;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DispatchAction =
|
||||
|
|
@ -792,6 +794,8 @@ export const DISPATCH_RULES: DispatchRule[] = [
|
|||
|
||||
// Only dispatch parallel if 2+ slices are ready
|
||||
if (researchReadySlices.length < 2) return null;
|
||||
if (researchReadySlices.length > MAX_PARALLEL_RESEARCH_SLICES)
|
||||
return null;
|
||||
|
||||
// #4414: If a previous parallel-research attempt escalated to a blocker
|
||||
// placeholder, skip this rule and fall through to per-slice research
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ function formatExecutorConstraints(
|
|||
/**
|
||||
* Returns a markdown bullet list of known context file paths for the given
|
||||
* milestone (and optionally slice). Falls back to a generic tool-agnostic
|
||||
* instruction when no GSD artifacts are found.
|
||||
* instruction when no SF artifacts are found.
|
||||
*
|
||||
* @param base - Absolute path to the project root.
|
||||
* @param mid - Milestone ID (e.g. `"M001"`).
|
||||
|
|
@ -508,10 +508,10 @@ export async function inlineDependencySummaries(
|
|||
}
|
||||
|
||||
/**
|
||||
* Load a well-known .gsd/ root file for optional inlining.
|
||||
* Load a well-known .sf/ root file for optional inlining.
|
||||
* Handles the existsSync check internally.
|
||||
*/
|
||||
export async function inlineGsdRootFile(
|
||||
export async function inlineSfRootFile(
|
||||
base: string,
|
||||
filename: string,
|
||||
label: string,
|
||||
|
|
@ -532,7 +532,7 @@ export async function inlineGsdRootFile(
|
|||
|
||||
/**
|
||||
* Inline decisions with optional milestone scoping from the DB.
|
||||
* Falls back to filesystem via inlineGsdRootFile only when DB is unavailable.
|
||||
* Falls back to filesystem via inlineSfRootFile only when DB is unavailable.
|
||||
*
|
||||
* Cascade logic (R005):
|
||||
* 1. Query with { milestoneId, scope } if scope provided
|
||||
|
|
@ -567,7 +567,7 @@ export async function inlineDecisionsFromDb(
|
|||
inlineLevel !== "full"
|
||||
? formatDecisionsCompact(decisions)
|
||||
: formatDecisionsForPrompt(decisions);
|
||||
return `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`;
|
||||
return `### Decisions\nSource: \`.sf/DECISIONS.md\`\n\n${formatted}`;
|
||||
}
|
||||
// DB available but cascade returned empty — intentional per D020, don't fall back to file
|
||||
return null;
|
||||
|
|
@ -579,12 +579,12 @@ export async function inlineDecisionsFromDb(
|
|||
);
|
||||
}
|
||||
// DB unavailable — fall back to filesystem
|
||||
return inlineGsdRootFile(base, "decisions.md", "Decisions");
|
||||
return inlineSfRootFile(base, "decisions.md", "Decisions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline requirements with optional milestone and slice scoping from the DB.
|
||||
* Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty.
|
||||
* Falls back to filesystem via inlineSfRootFile when DB unavailable or empty.
|
||||
*/
|
||||
export async function inlineRequirementsFromDb(
|
||||
base: string,
|
||||
|
|
@ -606,7 +606,7 @@ export async function inlineRequirementsFromDb(
|
|||
inlineLevel !== "full"
|
||||
? formatRequirementsCompact(requirements)
|
||||
: formatRequirementsForPrompt(requirements);
|
||||
return `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`;
|
||||
return `### Requirements\nSource: \`.sf/REQUIREMENTS.md\`\n\n${formatted}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -615,12 +615,12 @@ export async function inlineRequirementsFromDb(
|
|||
`inlineRequirementsFromDb failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return inlineGsdRootFile(base, "requirements.md", "Requirements");
|
||||
return inlineSfRootFile(base, "requirements.md", "Requirements");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline project context from the DB.
|
||||
* Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty.
|
||||
* Falls back to filesystem via inlineSfRootFile when DB unavailable or empty.
|
||||
*/
|
||||
export async function inlineProjectFromDb(
|
||||
base: string,
|
||||
|
|
@ -631,7 +631,7 @@ export async function inlineProjectFromDb(
|
|||
const { queryProject } = await import("./context-store.js");
|
||||
const content = queryProject();
|
||||
if (content) {
|
||||
return `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`;
|
||||
return `### Project\nSource: \`.sf/PROJECT.md\`\n\n${content}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -640,7 +640,7 @@ export async function inlineProjectFromDb(
|
|||
`inlineProjectFromDb failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return inlineGsdRootFile(base, "project.md", "Project");
|
||||
return inlineSfRootFile(base, "project.md", "Project");
|
||||
}
|
||||
|
||||
// ─── Stopwords for keyword extraction ─────────────────────────────────────
|
||||
|
|
@ -1579,7 +1579,7 @@ export async function buildDiscussMilestonePrompt(
|
|||
inlinedTemplates: discussTemplates,
|
||||
structuredQuestionsAvailable,
|
||||
commitInstruction:
|
||||
"Do not commit planning artifacts — .gsd/ is managed externally.",
|
||||
"Do not commit planning artifacts — .sf/ is managed externally.",
|
||||
fastPathInstruction: "",
|
||||
});
|
||||
|
||||
|
|
@ -2020,7 +2020,7 @@ async function renderSlicePrompt(options: {
|
|||
);
|
||||
const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
|
||||
const commitInstruction =
|
||||
"Do not commit — .gsd/ planning docs are managed externally and not tracked in git.";
|
||||
"Do not commit — .sf/ planning docs are managed externally and not tracked in git.";
|
||||
|
||||
return loadPrompt(promptTemplate, {
|
||||
workingDirectory: base,
|
||||
|
|
@ -2304,7 +2304,7 @@ export async function buildExecuteTaskPrompt(
|
|||
const overridesSection = formatOverridesSection(activeOverrides);
|
||||
|
||||
const runtimeContext = runtimeContent
|
||||
? `### Runtime Context\nSource: \`.gsd/RUNTIME.md\`\n\n${runtimeContent.trim()}`
|
||||
? `### Runtime Context\nSource: \`.sf/RUNTIME.md\`\n\n${runtimeContent.trim()}`
|
||||
: "";
|
||||
|
||||
// Compute verification budget for the executor's context window (issue #707)
|
||||
|
|
@ -2650,7 +2650,7 @@ export async function buildCompleteMilestonePrompt(
|
|||
);
|
||||
}
|
||||
|
||||
// Inline root GSD files (skip for minimal — completion can read these if needed)
|
||||
// Inline root SF files (skip for minimal — completion can read these if needed)
|
||||
if (inlineLevel !== "minimal") {
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
|
|
@ -2675,7 +2675,7 @@ export async function buildCompleteMilestonePrompt(
|
|||
extractKeywords(midTitle),
|
||||
);
|
||||
if (knowledgeInlineCM) inlined.push(knowledgeInlineCM);
|
||||
// Inline milestone context file (milestone-level, not GSD root)
|
||||
// Inline milestone context file (milestone-level, not SF root)
|
||||
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextInline = await inlineFileOptional(
|
||||
|
|
@ -2867,7 +2867,7 @@ export async function buildValidateMilestonePrompt(
|
|||
);
|
||||
}
|
||||
|
||||
// Inline root GSD files
|
||||
// Inline root SF files
|
||||
if (inlineLevel !== "minimal") {
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
|
|
@ -3206,7 +3206,7 @@ export async function buildReassessRoadmapPrompt(
|
|||
}
|
||||
|
||||
const reassessCommitInstruction =
|
||||
"Do not commit — .gsd/ planning docs are managed externally and not tracked in git.";
|
||||
"Do not commit — .sf/ planning docs are managed externally and not tracked in git.";
|
||||
|
||||
return loadPrompt("reassess-roadmap", {
|
||||
workingDirectory: base,
|
||||
|
|
@ -3398,8 +3398,7 @@ export async function buildParallelResearchSlicesPrompt(
|
|||
subagentModel?: string,
|
||||
): Promise<string> {
|
||||
// Build individual research-slice prompts for each slice in parallel.
|
||||
const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
|
||||
const subagentSections = await Promise.all(
|
||||
const entries = await Promise.all(
|
||||
slices.map(async (slice) => {
|
||||
const slicePrompt = await buildResearchSlicePrompt(
|
||||
mid,
|
||||
|
|
@ -3408,23 +3407,52 @@ export async function buildParallelResearchSlicesPrompt(
|
|||
slice.title,
|
||||
basePath,
|
||||
);
|
||||
const guardedPrompt = [
|
||||
"IMPORTANT CHILD-AGENT OVERRIDE:",
|
||||
"- You are already one member of the parent parallel research batch.",
|
||||
"- Do not call `subagent`, `await_subagent`, or any other delegation tool from inside this child run.",
|
||||
"- If the embedded research-slice prompt suggests a research swarm, treat that requirement as already satisfied by the parent dispatch and perform the slice research directly.",
|
||||
"",
|
||||
slicePrompt,
|
||||
].join("\n");
|
||||
return { slice, guardedPrompt };
|
||||
}),
|
||||
);
|
||||
|
||||
const subagentSections = entries.map(({ slice, guardedPrompt }) => {
|
||||
return [
|
||||
`### ${slice.id}: ${slice.title}`,
|
||||
"",
|
||||
`Use this as the prompt for a \`subagent\` call${modelSuffix} (agent: \`gsd-executor\` or the default agent):`,
|
||||
"Task payload:",
|
||||
"",
|
||||
"```",
|
||||
slicePrompt,
|
||||
guardedPrompt,
|
||||
"```",
|
||||
].join("\n");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const tasks = entries.map(({ guardedPrompt }) => {
|
||||
const task: {
|
||||
agent: string;
|
||||
task: string;
|
||||
cwd: string;
|
||||
model?: string;
|
||||
} = {
|
||||
agent: "worker",
|
||||
cwd: basePath,
|
||||
task: guardedPrompt,
|
||||
};
|
||||
if (subagentModel) task.model = subagentModel;
|
||||
return task;
|
||||
});
|
||||
const subagentCall = JSON.stringify({ tasks }, null, 2);
|
||||
|
||||
return loadPrompt("parallel-research-slices", {
|
||||
mid,
|
||||
midTitle,
|
||||
sliceCount: String(slices.length),
|
||||
sliceList: slices.map((s) => `- **${s.id}**: ${s.title}`).join("\n"),
|
||||
subagentCall,
|
||||
subagentPrompts: subagentSections.join("\n\n---\n\n"),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ import {
|
|||
resolveDynamicRoutingConfig,
|
||||
} from "./preferences-models.js";
|
||||
import {
|
||||
ensureGsdSymlink,
|
||||
ensureSfSymlink,
|
||||
isInheritedRepo,
|
||||
validateProjectId,
|
||||
} from "./repo-identity.js";
|
||||
|
|
@ -483,7 +483,7 @@ export async function bootstrapAutoSession(
|
|||
);
|
||||
}
|
||||
// Ensure symlink exists (handles fresh projects and post-migration)
|
||||
ensureGsdSymlink(base);
|
||||
ensureSfSymlink(base);
|
||||
|
||||
// Ensure .gitignore has baseline patterns.
|
||||
// ensureGitignore checks for git-tracked .sf/ files and skips the
|
||||
|
|
@ -499,7 +499,7 @@ export async function bootstrapAutoSession(
|
|||
if (manageGitignore !== false) untrackRuntimeFiles(base);
|
||||
|
||||
// Bootstrap milestones/ if it doesn't exist.
|
||||
// Check milestones/ directly — ensureGsdSymlink above already created .sf/,
|
||||
// Check milestones/ directly — ensureSfSymlink above already created .sf/,
|
||||
// so checking .sf/ existence would be dead code (#2942).
|
||||
const sfDir = join(base, ".sf");
|
||||
const milestonesPath = join(sfDir, "milestones");
|
||||
|
|
@ -1001,7 +1001,7 @@ export async function bootstrapAutoSession(
|
|||
// ── Auto-worktree setup ──
|
||||
s.originalBasePath = base;
|
||||
|
||||
const isUnderGsdWorktrees = (p: string): boolean => {
|
||||
const isUnderSfWorktrees = (p: string): boolean => {
|
||||
// Direct layout: /.sf/worktrees/
|
||||
const marker = `${pathSep}.sf${pathSep}worktrees${pathSep}`;
|
||||
if (p.includes(marker)) return true;
|
||||
|
|
@ -1018,7 +1018,7 @@ export async function bootstrapAutoSession(
|
|||
s.currentMilestoneId &&
|
||||
shouldUseWorktreeIsolation() &&
|
||||
!detectWorktreeName(base) &&
|
||||
!isUnderGsdWorktrees(base)
|
||||
!isUnderSfWorktrees(base)
|
||||
) {
|
||||
buildResolver().enterMilestone(s.currentMilestoneId, {
|
||||
notify: ctx.ui.notify.bind(ctx.ui),
|
||||
|
|
|
|||
|
|
@ -301,13 +301,13 @@ export function syncProjectRootToWorktree(
|
|||
if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
|
||||
if (!milestoneId) return;
|
||||
|
||||
const prGsd = join(projectRoot, ".sf");
|
||||
const wtGsd = join(worktreePath_, ".sf");
|
||||
const prSf = join(projectRoot, ".sf");
|
||||
const wtSf = join(worktreePath_, ".sf");
|
||||
|
||||
// When .sf is a symlink to the same external directory in both locations,
|
||||
// cpSync rejects the copy because source === destination (ERR_FS_CP_EINVAL).
|
||||
// Compare realpaths and skip when they resolve to the same physical path (#2184).
|
||||
if (isSamePath(prGsd, wtGsd)) return;
|
||||
if (isSamePath(prSf, wtSf)) return;
|
||||
|
||||
// Copy milestone directory from project root to worktree — additive only.
|
||||
// force:false prevents cpSync from overwriting existing worktree files.
|
||||
|
|
@ -315,8 +315,8 @@ export function syncProjectRootToWorktree(
|
|||
// by validate-milestone) get clobbered by stale project root copies,
|
||||
// causing an infinite re-validation loop (#1886).
|
||||
safeCopyRecursive(
|
||||
join(prGsd, "milestones", milestoneId),
|
||||
join(wtGsd, "milestones", milestoneId),
|
||||
join(prSf, "milestones", milestoneId),
|
||||
join(wtSf, "milestones", milestoneId),
|
||||
{ force: false },
|
||||
);
|
||||
|
||||
|
|
@ -329,16 +329,16 @@ export function syncProjectRootToWorktree(
|
|||
// persists, checkNeedsRunUat finds no passing verdict → re-dispatches
|
||||
// run-uat indefinitely (stuck-loop ×9).
|
||||
forceOverwriteAssessmentsWithVerdict(
|
||||
join(prGsd, "milestones", milestoneId),
|
||||
join(wtGsd, "milestones", milestoneId),
|
||||
join(prSf, "milestones", milestoneId),
|
||||
join(wtSf, "milestones", milestoneId),
|
||||
);
|
||||
|
||||
// Forward-sync completed-units.json from project root to worktree.
|
||||
// Project root is authoritative for completion state after crash recovery;
|
||||
// without this, the worktree re-dispatches already-completed units (#1886).
|
||||
safeCopy(
|
||||
join(prGsd, "completed-units.json"),
|
||||
join(wtGsd, "completed-units.json"),
|
||||
join(prSf, "completed-units.json"),
|
||||
join(wtSf, "completed-units.json"),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
|
|
@ -348,7 +348,7 @@ export function syncProjectRootToWorktree(
|
|||
// preserved — deleting it truncates the file to 0 bytes when
|
||||
// openDatabase re-creates it, causing "no such table" failures (#2815).
|
||||
try {
|
||||
const wtDb = join(wtGsd, "sf.db");
|
||||
const wtDb = join(wtSf, "sf.db");
|
||||
let deleteSidecars = false;
|
||||
if (existsSync(wtDb)) {
|
||||
const size = statSync(wtDb).size;
|
||||
|
|
@ -396,29 +396,29 @@ export function syncStateToProjectRoot(
|
|||
if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
|
||||
if (!milestoneId) return;
|
||||
|
||||
const wtGsd = join(worktreePath_, ".sf");
|
||||
const prGsd = join(projectRoot, ".sf");
|
||||
const wtSf = join(worktreePath_, ".sf");
|
||||
const prSf = join(projectRoot, ".sf");
|
||||
|
||||
// When .sf is a symlink to the same external directory in both locations,
|
||||
// cpSync rejects the copy because source === destination (ERR_FS_CP_EINVAL).
|
||||
// Compare realpaths and skip when they resolve to the same physical path (#2184).
|
||||
if (isSamePath(wtGsd, prGsd)) return;
|
||||
if (isSamePath(wtSf, prSf)) return;
|
||||
|
||||
// 1. STATE.md — the quick-glance status used by initial deriveState()
|
||||
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true });
|
||||
safeCopy(join(wtSf, "STATE.md"), join(prSf, "STATE.md"), { force: true });
|
||||
|
||||
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
|
||||
// Copy the entire milestone .sf subtree so deriveState reads current checkboxes
|
||||
safeCopyRecursive(
|
||||
join(wtGsd, "milestones", milestoneId),
|
||||
join(prGsd, "milestones", milestoneId),
|
||||
join(wtSf, "milestones", milestoneId),
|
||||
join(prSf, "milestones", milestoneId),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
// 3. metrics.json — session cost/token tracking (#2313).
|
||||
// Without this, metrics accumulated in the worktree are invisible from the
|
||||
// project root and never appear in the dashboard or skill-health reports.
|
||||
safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), {
|
||||
safeCopy(join(wtSf, "metrics.json"), join(prSf, "metrics.json"), {
|
||||
force: true,
|
||||
});
|
||||
|
||||
|
|
@ -427,8 +427,8 @@ export function syncStateToProjectRoot(
|
|||
// worktree. If the next session resolves basePath before worktree re-entry,
|
||||
// selfHeal can't find or clear the stale record (#769).
|
||||
safeCopyRecursive(
|
||||
join(wtGsd, "runtime", "units"),
|
||||
join(prGsd, "runtime", "units"),
|
||||
join(wtSf, "runtime", "units"),
|
||||
join(prSf, "runtime", "units"),
|
||||
{ force: true },
|
||||
);
|
||||
}
|
||||
|
|
@ -505,11 +505,11 @@ export function escapeStaleWorktree(base: string): string {
|
|||
// the string-slice heuristic matched the wrong /.sf/ boundary. This happens
|
||||
// when .sf is a symlink into ~/.sf/projects/<hash> and process.cwd()
|
||||
// resolved through the symlink. Returning ~ would be catastrophic (#1676).
|
||||
const candidateGsd = join(projectRoot, ".sf").replaceAll("\\", "/");
|
||||
const candidateSf = join(projectRoot, ".sf").replaceAll("\\", "/");
|
||||
const sfHomePath = sfHome.replaceAll("\\", "/");
|
||||
if (
|
||||
candidateGsd === sfHomePath ||
|
||||
candidateGsd.startsWith(sfHomePath + "/")
|
||||
candidateSf === sfHomePath ||
|
||||
candidateSf.startsWith(sfHomePath + "/")
|
||||
) {
|
||||
// Don't chdir to home — return base unchanged.
|
||||
// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
|
||||
|
|
@ -593,19 +593,19 @@ export function syncSfStateToWorktree(
|
|||
mainBasePath: string,
|
||||
worktreePath_: string,
|
||||
): { synced: string[] } {
|
||||
const mainGsd = sfRoot(mainBasePath);
|
||||
const wtGsd = sfRoot(worktreePath_);
|
||||
const mainSf = sfRoot(mainBasePath);
|
||||
const wtSf = sfRoot(worktreePath_);
|
||||
const synced: string[] = [];
|
||||
|
||||
// If both resolve to the same directory (symlink), no sync needed
|
||||
if (isSamePath(mainGsd, wtGsd)) return { synced };
|
||||
if (isSamePath(mainSf, wtSf)) return { synced };
|
||||
|
||||
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
|
||||
if (!existsSync(mainSf) || !existsSync(wtSf)) return { synced };
|
||||
|
||||
// Sync root-level .sf/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.)
|
||||
for (const f of ROOT_STATE_FILES) {
|
||||
const src = join(mainGsd, f);
|
||||
const dst = join(wtGsd, f);
|
||||
const src = join(mainSf, f);
|
||||
const dst = join(wtSf, f);
|
||||
if (existsSync(src) && !existsSync(dst)) {
|
||||
try {
|
||||
cpSync(src, dst);
|
||||
|
|
@ -625,15 +625,15 @@ export function syncSfStateToWorktree(
|
|||
// fallback so older repos still work on case-sensitive filesystems.
|
||||
{
|
||||
const worktreeHasPreferences =
|
||||
existsSync(join(wtGsd, PROJECT_PREFERENCES_FILE)) ||
|
||||
existsSync(join(wtGsd, LEGACY_PROJECT_PREFERENCES_FILE));
|
||||
existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)) ||
|
||||
existsSync(join(wtSf, LEGACY_PROJECT_PREFERENCES_FILE));
|
||||
if (!worktreeHasPreferences) {
|
||||
for (const file of [
|
||||
PROJECT_PREFERENCES_FILE,
|
||||
LEGACY_PROJECT_PREFERENCES_FILE,
|
||||
] as const) {
|
||||
const src = join(mainGsd, file);
|
||||
const dst = join(wtGsd, file);
|
||||
const src = join(mainSf, file);
|
||||
const dst = join(wtSf, file);
|
||||
if (existsSync(src)) {
|
||||
try {
|
||||
cpSync(src, dst);
|
||||
|
|
@ -652,8 +652,8 @@ export function syncSfStateToWorktree(
|
|||
}
|
||||
|
||||
// Sync milestones: copy entire milestone directories that are missing
|
||||
const mainMilestonesDir = join(mainGsd, "milestones");
|
||||
const wtMilestonesDir = join(wtGsd, "milestones");
|
||||
const mainMilestonesDir = join(mainSf, "milestones");
|
||||
const wtMilestonesDir = join(wtSf, "milestones");
|
||||
if (existsSync(mainMilestonesDir)) {
|
||||
try {
|
||||
mkdirSync(wtMilestonesDir, { recursive: true });
|
||||
|
|
@ -790,22 +790,22 @@ export function syncWorktreeStateBack(
|
|||
worktreePath: string,
|
||||
milestoneId: string,
|
||||
): { synced: string[] } {
|
||||
const mainGsd = sfRoot(mainBasePath);
|
||||
const wtGsd = sfRoot(worktreePath);
|
||||
const mainSf = sfRoot(mainBasePath);
|
||||
const wtSf = sfRoot(worktreePath);
|
||||
const synced: string[] = [];
|
||||
|
||||
// If both resolve to the same directory (symlink), no sync needed
|
||||
if (isSamePath(mainGsd, wtGsd)) return { synced };
|
||||
if (isSamePath(mainSf, wtSf)) return { synced };
|
||||
|
||||
if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced };
|
||||
if (!existsSync(wtSf) || !existsSync(mainSf)) return { synced };
|
||||
|
||||
// ── 0. Pre-upgrade worktree DB reconciliation ────────────────────────
|
||||
// If the worktree has its own sf.db (copied before the WAL transition),
|
||||
// reconcile its hierarchy data into the project root DB before syncing
|
||||
// files. This handles in-flight worktrees that were created before the
|
||||
// upgrade to shared WAL mode.
|
||||
const wtLocalDb = join(wtGsd, "sf.db");
|
||||
const mainDb = join(mainGsd, "sf.db");
|
||||
const wtLocalDb = join(wtSf, "sf.db");
|
||||
const mainDb = join(mainSf, "sf.db");
|
||||
if (existsSync(wtLocalDb) && existsSync(mainDb)) {
|
||||
try {
|
||||
reconcileWorktreeDb(mainDb, wtLocalDb);
|
||||
|
|
@ -826,8 +826,8 @@ export function syncWorktreeStateBack(
|
|||
// written during milestone closeout and lost on teardown without explicit sync
|
||||
// (#1787, #2313).
|
||||
for (const f of ROOT_STATE_FILES) {
|
||||
const src = join(wtGsd, f);
|
||||
const dst = join(mainGsd, f);
|
||||
const src = join(wtSf, f);
|
||||
const dst = join(mainSf, f);
|
||||
if (existsSync(src)) {
|
||||
try {
|
||||
cpSync(src, dst, { force: true });
|
||||
|
|
@ -846,7 +846,7 @@ export function syncWorktreeStateBack(
|
|||
// 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");
|
||||
const wtMilestonesDir = join(wtSf, "milestones");
|
||||
if (!existsSync(wtMilestonesDir)) return { synced };
|
||||
|
||||
try {
|
||||
|
|
@ -858,7 +858,7 @@ export function syncWorktreeStateBack(
|
|||
// Skip the current milestone being merged — its files are already in the
|
||||
// milestone branch and would conflict with the squash merge (#3641).
|
||||
if (mid === milestoneId) continue;
|
||||
syncMilestoneDir(wtGsd, mainGsd, mid, synced);
|
||||
syncMilestoneDir(wtSf, mainSf, mid, synced);
|
||||
}
|
||||
} catch (err) {
|
||||
/* non-fatal */
|
||||
|
|
@ -909,13 +909,13 @@ function syncDirFiles(
|
|||
}
|
||||
|
||||
function syncMilestoneDir(
|
||||
wtGsd: string,
|
||||
mainGsd: string,
|
||||
wtSf: string,
|
||||
mainSf: string,
|
||||
mid: string,
|
||||
synced: string[],
|
||||
): void {
|
||||
const wtMilestoneDir = join(wtGsd, "milestones", mid);
|
||||
const mainMilestoneDir = join(mainGsd, "milestones", mid);
|
||||
const wtMilestoneDir = join(wtSf, "milestones", mid);
|
||||
const mainMilestoneDir = join(mainSf, "milestones", mid);
|
||||
|
||||
if (!existsSync(wtMilestoneDir)) return;
|
||||
mkdirSync(mainMilestoneDir, { recursive: true });
|
||||
|
|
@ -1264,13 +1264,13 @@ export function createAutoWorktree(
|
|||
* Best-effort — failures are non-fatal since auto-mode can recreate artifacts.
|
||||
*/
|
||||
function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
|
||||
const srcGsd = join(srcBase, ".sf");
|
||||
const dstGsd = join(wtPath, ".sf");
|
||||
if (!existsSync(srcGsd)) return;
|
||||
if (isSamePath(srcGsd, dstGsd)) return;
|
||||
const srcSf = join(srcBase, ".sf");
|
||||
const dstSf = join(wtPath, ".sf");
|
||||
if (!existsSync(srcSf)) return;
|
||||
if (isSamePath(srcSf, dstSf)) return;
|
||||
|
||||
// Copy milestones/ directory (planning files, roadmaps, plans, research)
|
||||
safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), {
|
||||
safeCopyRecursive(join(srcSf, "milestones"), join(dstSf, "milestones"), {
|
||||
force: true,
|
||||
filter: (src) => !src.endsWith("-META.json"),
|
||||
});
|
||||
|
|
@ -1286,20 +1286,20 @@ function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
|
|||
"OVERRIDES.md",
|
||||
"mcp.json",
|
||||
]) {
|
||||
safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true });
|
||||
safeCopy(join(srcSf, file), join(dstSf, file), { force: true });
|
||||
}
|
||||
|
||||
// Seed canonical PREFERENCES.md when available; fall back to legacy lowercase.
|
||||
if (existsSync(join(srcGsd, PROJECT_PREFERENCES_FILE))) {
|
||||
if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) {
|
||||
safeCopy(
|
||||
join(srcGsd, PROJECT_PREFERENCES_FILE),
|
||||
join(dstGsd, PROJECT_PREFERENCES_FILE),
|
||||
join(srcSf, PROJECT_PREFERENCES_FILE),
|
||||
join(dstSf, PROJECT_PREFERENCES_FILE),
|
||||
{ force: true },
|
||||
);
|
||||
} else if (existsSync(join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE))) {
|
||||
} else if (existsSync(join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE))) {
|
||||
safeCopy(
|
||||
join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE),
|
||||
join(dstGsd, LEGACY_PROJECT_PREFERENCES_FILE),
|
||||
join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE),
|
||||
join(dstSf, LEGACY_PROJECT_PREFERENCES_FILE),
|
||||
{ force: true },
|
||||
);
|
||||
}
|
||||
|
|
@ -2077,7 +2077,7 @@ export function mergeMilestoneToMain(
|
|||
// version) and drop the now-applied stash.
|
||||
const uu = nativeConflictFiles(originalBasePath_);
|
||||
const sfUU = uu.filter((f) => f.startsWith(".sf/"));
|
||||
const nonGsdUU = uu.filter((f) => !f.startsWith(".sf/"));
|
||||
const nonSfUU = uu.filter((f) => !f.startsWith(".sf/"));
|
||||
|
||||
if (sfUU.length > 0) {
|
||||
for (const f of sfUU) {
|
||||
|
|
@ -2100,7 +2100,7 @@ export function mergeMilestoneToMain(
|
|||
}
|
||||
}
|
||||
|
||||
if (nonGsdUU.length === 0) {
|
||||
if (nonSfUU.length === 0) {
|
||||
// All conflicts were .sf/ files — safe to drop the stash
|
||||
try {
|
||||
execFileSync("git", ["stash", "drop"], {
|
||||
|
|
@ -2121,7 +2121,7 @@ export function mergeMilestoneToMain(
|
|||
"reconcile",
|
||||
"Stash pop conflict on non-.sf files after merge",
|
||||
{
|
||||
files: nonGsdUU.join(", "),
|
||||
files: nonSfUU.join(", "),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1831,14 +1831,14 @@ export async function runUnitPhase(
|
|||
s.lastBaselineCharCount = undefined;
|
||||
if (deps.isDbAvailable()) {
|
||||
try {
|
||||
const { inlineGsdRootFile } = await importExtensionModule<
|
||||
const { inlineSfRootFile } = await importExtensionModule<
|
||||
typeof import("../auto-prompts.js")
|
||||
>(import.meta.url, "../auto-prompts.js");
|
||||
const [decisionsContent, requirementsContent, projectContent] =
|
||||
await Promise.all([
|
||||
inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
|
||||
inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
|
||||
inlineGsdRootFile(s.basePath, "project.md", "Project"),
|
||||
inlineSfRootFile(s.basePath, "decisions.md", "Decisions"),
|
||||
inlineSfRootFile(s.basePath, "requirements.md", "Requirements"),
|
||||
inlineSfRootFile(s.basePath, "project.md", "Project"),
|
||||
]);
|
||||
s.lastBaselineCharCount =
|
||||
(decisionsContent?.length ?? 0) +
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { join } from "node:path";
|
||||
import { join, resolve, relative } from "node:path";
|
||||
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
|
|
@ -456,6 +456,33 @@ export function registerHooks(
|
|||
|
||||
if (!isToolCallEventType("write", event)) return;
|
||||
|
||||
// ── Worktree isolation: block writes outside the worktree and main .sf/ ──
|
||||
// Only enforced in auto-mode — interactive sessions skip this check.
|
||||
// When SF_WORKTREE is set, process.cwd() is the worktree directory.
|
||||
// The agent should only write inside the worktree OR inside the main repo's .sf/.
|
||||
if (isAutoActive() && process.env.SF_WORKTREE) {
|
||||
const worktreeRoot = process.cwd();
|
||||
const mainRepoRoot =
|
||||
process.env.SF_PROJECT_ROOT ??
|
||||
(resolve(worktreeRoot, ".."));
|
||||
const targetPath = resolve(event.input.path);
|
||||
const worktreeRel = relative(worktreeRoot, targetPath);
|
||||
const mainSfRel = relative(join(mainRepoRoot, ".sf"), targetPath);
|
||||
const worktreeOk =
|
||||
!worktreeRel.startsWith("..") && !worktreeRel.startsWith("/");
|
||||
const mainSfOk =
|
||||
!mainSfRel.startsWith("..") && !mainSfRel.startsWith("/");
|
||||
if (!worktreeOk && !mainSfOk) {
|
||||
return {
|
||||
block: true,
|
||||
reason:
|
||||
`HARD BLOCK: Worktree isolation is active. Cannot write to "${event.input.path}" — ` +
|
||||
`path is outside the worktree (${worktreeRoot}) and outside the main repo's .sf/ directory. ` +
|
||||
`Write only inside the worktree or inside ${join(mainRepoRoot, ".sf")}/milestones/ for planning artifacts.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = shouldBlockContextWrite(
|
||||
event.toolName,
|
||||
event.input.path,
|
||||
|
|
@ -500,55 +527,33 @@ export function registerHooks(
|
|||
const details = event.details as any;
|
||||
|
||||
// ── Discussion gate enforcement: handle gate question responses ──
|
||||
// If the result is cancelled or has no response, the pending gate stays active
|
||||
// so the model is blocked from non-read-only tools until it re-asks.
|
||||
// If the user responded at all (even "needs adjustment"), clear the pending gate
|
||||
// because the user engaged — the prompt handles the re-ask-after-adjustment flow.
|
||||
// Single consolidated loop: finds depth_verification questions, verifies the answer,
|
||||
// marks the milestone as depth-verified, and clears the pending gate.
|
||||
// Also handles the legacy pending-gate path (set by tool_call) for robustness.
|
||||
const questions: any[] = (event.input as any)?.questions ?? [];
|
||||
const currentPendingGate = getPendingGate();
|
||||
if (currentPendingGate) {
|
||||
if (details?.cancelled || !details?.response) {
|
||||
// Gate stays pending — model will be blocked from non-read-only tools
|
||||
// until it re-asks and gets a valid response
|
||||
} else {
|
||||
const pendingQuestion = questions.find(
|
||||
(question) => question?.id === currentPendingGate,
|
||||
);
|
||||
if (pendingQuestion) {
|
||||
const answer = details.response?.answers?.[currentPendingGate];
|
||||
if (
|
||||
isDepthConfirmationAnswer(
|
||||
getSelectedGateAnswer(answer),
|
||||
pendingQuestion.options,
|
||||
)
|
||||
) {
|
||||
clearPendingGate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (details?.cancelled || !details?.response) return;
|
||||
|
||||
for (const question of questions) {
|
||||
if (
|
||||
typeof question.id === "string" &&
|
||||
question.id.includes("depth_verification")
|
||||
) {
|
||||
// Only unlock the gate if the user selected the first option (confirmation).
|
||||
// Cross-references against the question's defined options to reject free-form "Other" text.
|
||||
if (typeof question.id !== "string") continue;
|
||||
|
||||
// Check if this is a depth_verification question (either directly or via pending gate)
|
||||
const isDepthQ = question.id.includes("depth_verification");
|
||||
const isPendingQ = question.id === currentPendingGate;
|
||||
if (!isDepthQ && !isPendingQ) continue;
|
||||
|
||||
const answer = details.response?.answers?.[question.id];
|
||||
if (
|
||||
isDepthConfirmationAnswer(getSelectedGateAnswer(answer), question.options)
|
||||
) {
|
||||
// Always mark depth-verified AND clear the gate
|
||||
if (isDepthQ) {
|
||||
const inferredMilestoneId =
|
||||
extractDepthVerificationMilestoneId(question.id) ?? milestoneId;
|
||||
if (
|
||||
isDepthConfirmationAnswer(
|
||||
getSelectedGateAnswer(answer),
|
||||
question.options,
|
||||
)
|
||||
) {
|
||||
markDepthVerified(inferredMilestoneId);
|
||||
clearPendingGate();
|
||||
}
|
||||
clearPendingGate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ function filterStartsWith(
|
|||
}));
|
||||
}
|
||||
|
||||
function getGsdArgumentCompletions(prefix: string) {
|
||||
function getSfArgumentCompletions(prefix: string) {
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
|
|
@ -382,7 +382,7 @@ function getGsdArgumentCompletions(prefix: string) {
|
|||
export function registerLazySFCommand(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("sf", {
|
||||
description: "SF — Singularity Forge",
|
||||
getArgumentCompletions: getGsdArgumentCompletions,
|
||||
getArgumentCompletions: getSfArgumentCompletions,
|
||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||
const { handleSFCommand } = await importExtensionModule<
|
||||
typeof import("./commands.js")
|
||||
|
|
|
|||
|
|
@ -7,17 +7,17 @@ import { resolveProjectRoot } from "../worktree.js";
|
|||
|
||||
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||
|
||||
export interface GsdCommandDefinition {
|
||||
export interface SfCommandDefinition {
|
||||
cmd: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
|
||||
type CompletionMap = Record<string, readonly SfCommandDefinition[]>;
|
||||
|
||||
export const SF_COMMAND_DESCRIPTION =
|
||||
"SF — Singularity Forge: /sf help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan";
|
||||
|
||||
export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
|
||||
export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
|
||||
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
||||
{ cmd: "next", desc: "Explicit step mode (same as /sf)" },
|
||||
{
|
||||
|
|
@ -387,7 +387,7 @@ const NESTED_COMPLETIONS: CompletionMap = {
|
|||
|
||||
function filterOptions(
|
||||
partial: string,
|
||||
options: readonly GsdCommandDefinition[],
|
||||
options: readonly SfCommandDefinition[],
|
||||
prefix = "",
|
||||
) {
|
||||
const normalizedPrefix = prefix ? `${prefix} ` : "";
|
||||
|
|
@ -429,7 +429,7 @@ function getExtensionCompletions(prefix: string, action: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getGsdArgumentCompletions(prefix: string) {
|
||||
export function getSfArgumentCompletions(prefix: string) {
|
||||
const hasTrailingSpace = prefix.endsWith(" ");
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
if (hasTrailingSpace && parts.length >= 1) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { validateDirectory } from "../validate-directory.js";
|
|||
import { resolveProjectRoot } from "../worktree.js";
|
||||
import { handleStatus } from "./handlers/core.js";
|
||||
|
||||
export interface GsdDispatchContext {
|
||||
export interface SfDispatchContext {
|
||||
ctx: ExtensionCommandContext;
|
||||
pi: ExtensionAPI;
|
||||
trimmed: string;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import type {
|
|||
} from "@singularity-forge/pi-coding-agent";
|
||||
|
||||
import {
|
||||
getGsdArgumentCompletions,
|
||||
getSfArgumentCompletions,
|
||||
SF_COMMAND_DESCRIPTION,
|
||||
} from "./catalog.js";
|
||||
|
||||
export function registerSFCommand(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("sf", {
|
||||
description: SF_COMMAND_DESCRIPTION,
|
||||
getArgumentCompletions: getGsdArgumentCompletions,
|
||||
getArgumentCompletions: getSfArgumentCompletions,
|
||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||
const { handleSFCommand } = await import("./dispatcher.js");
|
||||
const { setStderrLoggingEnabled } = await import("../workflow-logger.js");
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ const MAX_RECURSIVE_SCAN_DEPTH = 6;
|
|||
*/
|
||||
export function detectProjectState(basePath: string): ProjectDetection {
|
||||
const v1 = detectV1Planning(basePath);
|
||||
const v2 = detectV2Gsd(basePath);
|
||||
const v2 = detectV2Sf(basePath);
|
||||
const projectSignals = detectProjectSignals(basePath);
|
||||
const globalSetup = hasGlobalSetup();
|
||||
const firstEver = isFirstEverLaunch();
|
||||
|
|
@ -372,7 +372,7 @@ export function detectV1Planning(basePath: string): V1Detection | null {
|
|||
|
||||
// ─── V2 SF Detection ──────────────────────────────────────────────────────────
|
||||
|
||||
function detectV2Gsd(basePath: string): V2Detection | null {
|
||||
function detectV2Sf(basePath: string): V2Detection | null {
|
||||
const sfPath = sfRoot(basePath);
|
||||
|
||||
if (!existsSync(sfPath)) return null;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "./crash-recovery.js";
|
||||
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
||||
import { saveFile } from "./files.js";
|
||||
import { ensureGitignore, isGsdGitignored } from "./gitignore.js";
|
||||
import { ensureGitignore, isSfGitignored } from "./gitignore.js";
|
||||
import { recoverFailedMigration } from "./migrate-external.js";
|
||||
import {
|
||||
nativeForEachRef,
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
nativeUpdateRef,
|
||||
} from "./native-git-bridge.js";
|
||||
import { milestonesDir, resolveSfRootFile, sfRoot } from "./paths.js";
|
||||
import { cleanNumberedGsdVariants } from "./repo-identity.js";
|
||||
import { cleanNumberedSfVariants } from "./repo-identity.js";
|
||||
import {
|
||||
isSessionStale,
|
||||
readAllSessionStatuses,
|
||||
|
|
@ -431,27 +431,27 @@ export async function checkRuntimeHealth(
|
|||
});
|
||||
}
|
||||
|
||||
// ── Symlinked .gsd without .gitignore entry (#4423) ──
|
||||
// When `.gsd` is a symlink AND not gitignored, `git add -A -- :!.gsd/...`
|
||||
// ── Symlinked .sf without .gitignore entry (#4423) ──
|
||||
// When `.sf` is a symlink AND not gitignored, `git add -A -- :!.sf/...`
|
||||
// pathspecs fail with "beyond a symbolic link". Without self-heal this
|
||||
// silently drops new user files during auto-commit.
|
||||
if (nativeIsRepo(basePath) && !isGsdGitignored(basePath)) {
|
||||
if (nativeIsRepo(basePath) && !isSfGitignored(basePath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "symlinked_gsd_unignored",
|
||||
code: "symlinked_sf_unignored",
|
||||
scope: "project",
|
||||
unitId: "project",
|
||||
message:
|
||||
".gsd is a symlink to external state but is not listed in .gitignore. This causes git pathspec exclusions to fail and can lead to silently dropped new files during auto-commit. Add `.gsd` to .gitignore.",
|
||||
".sf is a symlink to external state but is not listed in .gitignore. This causes git pathspec exclusions to fail and can lead to silently dropped new files during auto-commit. Add `.sf` to .gitignore.",
|
||||
file: ".gitignore",
|
||||
fixable: true,
|
||||
});
|
||||
|
||||
if (shouldFix("symlinked_gsd_unignored")) {
|
||||
if (shouldFix("symlinked_sf_unignored")) {
|
||||
const modified = ensureGitignore(basePath);
|
||||
if (modified)
|
||||
fixesApplied.push(
|
||||
"added .gsd to .gitignore (symlinked external state)",
|
||||
"added .sf to .gitignore (symlinked external state)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -482,7 +482,7 @@ export async function checkRuntimeHealth(
|
|||
}
|
||||
|
||||
if (shouldFix("numbered_sf_variant")) {
|
||||
const removed = cleanNumberedGsdVariants(basePath);
|
||||
const removed = cleanNumberedSfVariants(basePath);
|
||||
for (const name of removed) {
|
||||
fixesApplied.push(`removed numbered .sf variant: ${name}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type DoctorIssueCode =
|
|||
| "state_file_stale"
|
||||
| "state_file_missing"
|
||||
| "gitignore_missing_patterns"
|
||||
| "symlinked_gsd_unignored"
|
||||
| "symlinked_sf_unignored"
|
||||
| "unresolvable_dependency"
|
||||
| "failed_migration"
|
||||
| "broken_symlink"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ const BASELINE_PATTERNS = [
|
|||
* - `.sf` is not listed in any active ignore rule
|
||||
* - Not a git repo or git is unavailable
|
||||
*/
|
||||
export function isGsdGitignored(basePath: string): boolean {
|
||||
export function isSfGitignored(basePath: string): boolean {
|
||||
// Check both `.sf` and `.sf/` because `.sf/` in .gitignore (trailing
|
||||
// slash = directory-only pattern) only matches the directory form. Using
|
||||
// both paths covers all gitignore pattern variants.
|
||||
|
|
@ -149,7 +149,7 @@ export function isGsdGitignored(basePath: string): boolean {
|
|||
* - `.sf/` doesn't exist
|
||||
* - No tracked files found under `.sf/`
|
||||
*/
|
||||
export function hasGitTrackedGsdFiles(basePath: string): boolean {
|
||||
export function hasGitTrackedSfFiles(basePath: string): boolean {
|
||||
const localSf = join(basePath, ".sf");
|
||||
|
||||
// If .sf doesn't exist or is already a symlink, no tracked files concern
|
||||
|
|
@ -266,7 +266,7 @@ export function ensureGitignore(
|
|||
|
||||
// Determine which patterns to apply. If .sf/ has tracked files,
|
||||
// exclude the ".sf" pattern to prevent deleting tracked state.
|
||||
const sfIsTracked = hasGitTrackedGsdFiles(basePath);
|
||||
const sfIsTracked = hasGitTrackedSfFiles(basePath);
|
||||
const patternsToApply = sfIsTracked
|
||||
? BASELINE_PATTERNS.filter((p) => p !== ".sf")
|
||||
: BASELINE_PATTERNS;
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ export async function showProjectInit(
|
|||
}
|
||||
|
||||
// ── Step 9: Bootstrap .sf/ ────────────────────────────────────────────────
|
||||
bootstrapGsdDirectory(basePath, prefs, signals);
|
||||
bootstrapSfDirectory(basePath, prefs, signals);
|
||||
|
||||
// Initialize SQLite database so SF starts in full-capability mode (#3880).
|
||||
// Without this, isDbAvailable() returns false and SF enters degraded
|
||||
|
|
@ -572,7 +572,7 @@ async function customizeAdvancedPrefs(
|
|||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function bootstrapGsdDirectory(
|
||||
function bootstrapSfDirectory(
|
||||
basePath: string,
|
||||
prefs: ProjectPreferences,
|
||||
signals: ProjectSignals,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface McpConfigFile {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function resolveBundledGsdCliPath(
|
||||
export function resolveBundledSfCliPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
const explicit = env.SF_CLI_PATH?.trim() || env.SF_BIN_PATH?.trim();
|
||||
|
|
@ -55,7 +55,7 @@ export function buildProjectWorkflowMcpServerConfig(
|
|||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ProjectMcpServerConfig {
|
||||
const resolvedProjectRoot = resolve(projectRoot);
|
||||
const sfCliPath = resolveBundledGsdCliPath(env);
|
||||
const sfCliPath = resolveBundledSfCliPath(env);
|
||||
const launch = detectWorkflowMcpLaunchConfig(resolvedProjectRoot, {
|
||||
...env,
|
||||
...(sfCliPath ? { SF_CLI_PATH: sfCliPath, SF_BIN_PATH: sfCliPath } : {}),
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { getErrorMessage } from "./error-utils.js";
|
||||
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
|
||||
import { hasGitTrackedGsdFiles } from "./gitignore.js";
|
||||
import { externalGsdRoot, isInsideWorktree } from "./repo-identity.js";
|
||||
import { hasGitTrackedSfFiles } from "./gitignore.js";
|
||||
import { externalSfRoot, isInsideWorktree } from "./repo-identity.js";
|
||||
|
||||
export interface MigrationResult {
|
||||
migrated: boolean;
|
||||
|
|
@ -46,7 +46,7 @@ export interface MigrationResult {
|
|||
export function migrateToExternalState(basePath: string): MigrationResult {
|
||||
// Worktrees get their .sf via syncSfStateToWorktree(), not migration.
|
||||
// Migration inside a worktree would compute the same external hash as the
|
||||
// main repo (externalGsdRoot hashes remoteUrl + gitRoot), creating a broken
|
||||
// main repo (externalSfRoot hashes remoteUrl + gitRoot), creating a broken
|
||||
// junction and orphaning .sf.migrating (#2970).
|
||||
if (isInsideWorktree(basePath)) {
|
||||
return { migrated: false };
|
||||
|
|
@ -80,7 +80,7 @@ export function migrateToExternalState(basePath: string): MigrationResult {
|
|||
|
||||
// Skip if .sf/ contains git-tracked files — the project intentionally
|
||||
// keeps .sf/ in version control and migration would destroy that.
|
||||
if (hasGitTrackedGsdFiles(basePath)) {
|
||||
if (hasGitTrackedSfFiles(basePath)) {
|
||||
return { migrated: false };
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ export function migrateToExternalState(basePath: string): MigrationResult {
|
|||
}
|
||||
}
|
||||
|
||||
const externalPath = externalGsdRoot(basePath);
|
||||
const externalPath = externalSfRoot(basePath);
|
||||
const migratingPath = join(basePath, ".sf.migrating");
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -152,8 +152,8 @@ export async function handleMigrate(
|
|||
);
|
||||
}
|
||||
|
||||
const targetGsdExists = existsSync(sfRoot(process.cwd()));
|
||||
if (targetGsdExists) {
|
||||
const targetSfExists = existsSync(sfRoot(process.cwd()));
|
||||
if (targetSfExists) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"⚠ A .sf directory already exists in the current working directory — it will be overwritten.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// GSD-2 — Milestone scope classifier (#4781 / ADR-003 companion).
|
||||
// SF — Milestone scope classifier (#4781 / ADR-003 companion).
|
||||
//
|
||||
// Pure heuristics over milestone planning fields. Produces a PipelineVariant
|
||||
// that downstream dispatch logic can use to shape the auto-mode sequence.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ let nativeModule: {
|
|||
level?: number,
|
||||
) => { content: string; found: boolean };
|
||||
extractAllSections: (content: string, level?: number) => string;
|
||||
batchParseGsdFiles: (directory: string) => {
|
||||
batchParseSfFiles: (directory: string) => {
|
||||
files: Array<{
|
||||
path: string;
|
||||
metadata: string;
|
||||
|
|
@ -47,7 +47,7 @@ let nativeModule: {
|
|||
consumes: string;
|
||||
}>;
|
||||
};
|
||||
scanGsdTree: (
|
||||
scanSfTree: (
|
||||
directory: string,
|
||||
) => Array<{ path: string; name: string; isDir: boolean }>;
|
||||
parseJsonlTail: (
|
||||
|
|
@ -70,7 +70,7 @@ function loadNative(): typeof nativeModule {
|
|||
// Dynamic import to avoid hard dependency - fails gracefully if native module not built
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require("@singularity-forge/native");
|
||||
if (mod.parseFrontmatter && mod.extractSection && mod.batchParseGsdFiles) {
|
||||
if (mod.parseFrontmatter && mod.extractSection && mod.batchParseSfFiles) {
|
||||
nativeModule = mod;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -161,13 +161,13 @@ export interface BatchParsedFile {
|
|||
* Batch-parse all .md files in a .sf/ directory tree using the native parser.
|
||||
* Returns null if native module unavailable.
|
||||
*/
|
||||
export function nativeBatchParseGsdFiles(
|
||||
export function nativeBatchParseSfFiles(
|
||||
directory: string,
|
||||
): BatchParsedFile[] | null {
|
||||
const native = loadNative();
|
||||
if (!native) return null;
|
||||
|
||||
const result = native.batchParseGsdFiles(directory);
|
||||
const result = native.batchParseSfFiles(directory);
|
||||
return result.files.map((f) => ({
|
||||
path: f.path,
|
||||
metadata: JSON.parse(f.metadata) as Record<string, unknown>,
|
||||
|
|
@ -186,7 +186,7 @@ export function isNativeParserAvailable(): boolean {
|
|||
|
||||
// ─── Tree Scanning ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GsdTreeEntry {
|
||||
export interface SfTreeEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
|
|
@ -196,10 +196,10 @@ export interface GsdTreeEntry {
|
|||
* Native-backed directory tree scan of a .sf/ directory.
|
||||
* Returns a flat list of all entries, or null if native module unavailable.
|
||||
*/
|
||||
export function nativeScanGsdTree(directory: string): GsdTreeEntry[] | null {
|
||||
export function nativeScanSfTree(directory: string): SfTreeEntry[] | null {
|
||||
const native = loadNative();
|
||||
if (!native) return null;
|
||||
return native.scanGsdTree(directory);
|
||||
return native.scanSfTree(directory);
|
||||
}
|
||||
|
||||
// ─── JSONL Parsing ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -645,7 +645,7 @@ export function spawnWorker(basePath: string, milestoneId: string): boolean {
|
|||
if (worker.process) return true; // already spawned
|
||||
|
||||
// Resolve the SF CLI binary path
|
||||
const binPath = resolveGsdBin();
|
||||
const binPath = resolveSfBin();
|
||||
if (!binPath) return false;
|
||||
|
||||
let child: ChildProcess;
|
||||
|
|
@ -800,7 +800,7 @@ export function spawnWorker(basePath: string, milestoneId: string): boolean {
|
|||
* Uses SF_BIN_PATH env var (set by loader.ts) or falls back to
|
||||
* finding the binary relative to the current module.
|
||||
*/
|
||||
function resolveGsdBin(): string | null {
|
||||
function resolveSfBin(): string | null {
|
||||
// SF_BIN_PATH is set by loader.ts to the absolute path of dist/loader.js
|
||||
if (process.env.SF_BIN_PATH && existsSync(process.env.SF_BIN_PATH)) {
|
||||
return process.env.SF_BIN_PATH;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { Dirent, existsSync, readdirSync, realpathSync } from "node:fs";
|
|||
import { dirname, join, normalize } from "node:path";
|
||||
import { DIR_CACHE_MAX } from "./constants.js";
|
||||
import {
|
||||
type GsdTreeEntry,
|
||||
nativeScanGsdTree,
|
||||
type SfTreeEntry,
|
||||
nativeScanSfTree,
|
||||
} from "./native-parser-bridge.js";
|
||||
|
||||
// ─── Directory Listing Cache ──────────────────────────────────────────────────
|
||||
|
|
@ -27,17 +27,17 @@ const dirListCache = new Map<string, string[]>();
|
|||
// When the native module is available, scan the entire .sf/ tree in one call
|
||||
// and serve directory listings from memory instead of individual readdirSync calls.
|
||||
|
||||
let nativeTreeCache: Map<string, GsdTreeEntry[]> | null = null;
|
||||
let nativeTreeCache: Map<string, SfTreeEntry[]> | null = null;
|
||||
let nativeTreeBase: string | null = null;
|
||||
|
||||
function _getNativeTree(sfDir: string): Map<string, GsdTreeEntry[]> | null {
|
||||
function _getNativeTree(sfDir: string): Map<string, SfTreeEntry[]> | null {
|
||||
if (nativeTreeCache && nativeTreeBase === sfDir) return nativeTreeCache;
|
||||
|
||||
const entries = nativeScanGsdTree(sfDir);
|
||||
const entries = nativeScanSfTree(sfDir);
|
||||
if (!entries) return null;
|
||||
|
||||
// Build a map of parent directory -> entries
|
||||
const tree = new Map<string, GsdTreeEntry[]>();
|
||||
const tree = new Map<string, SfTreeEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const parts = entry.path.split("/");
|
||||
const parentPath = parts.slice(0, -1).join("/");
|
||||
|
|
@ -298,7 +298,7 @@ const LEGACY_SF_ROOT_FILES: Record<SFRootFileKey, string> = {
|
|||
const sfRootCache = new Map<string, string>();
|
||||
|
||||
/** Exported for tests only — do not call in production code. */
|
||||
export function _clearGsdRootCache(): void {
|
||||
export function _clearSfRootCache(): void {
|
||||
sfRootCache.clear();
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ export function sfRoot(basePath: string): string {
|
|||
const cached = sfRootCache.get(basePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = probeGsdRoot(basePath);
|
||||
const result = probeSfRoot(basePath);
|
||||
sfRootCache.set(basePath, result);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -334,7 +334,7 @@ export const projectRoot = sfRoot;
|
|||
* Matches both forward-slash and platform-native separators to handle
|
||||
* Windows paths (path.sep = '\\') and normalized Unix paths.
|
||||
*/
|
||||
function isInsideGsdWorktree(p: string): boolean {
|
||||
function isInsideSfWorktree(p: string): boolean {
|
||||
// Match /.sf/worktrees/<name> where <name> is the final segment or
|
||||
// followed by a separator. The <name> segment must be non-empty.
|
||||
const sepFwd = "/";
|
||||
|
|
@ -356,7 +356,7 @@ function isInsideGsdWorktree(p: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function probeGsdRoot(rawBasePath: string): string {
|
||||
function probeSfRoot(rawBasePath: string): string {
|
||||
// 1. Fast path — check the input path directly
|
||||
const local = join(rawBasePath, ".sf");
|
||||
if (existsSync(local)) return local;
|
||||
|
|
@ -366,7 +366,7 @@ function probeGsdRoot(rawBasePath: string): string {
|
|||
// the git-root probe (step 2) or walk-up (step 3) escapes to the project
|
||||
// root's .sf, causing ensurePreconditions() and deriveState() to read/write
|
||||
// state in the wrong location.
|
||||
if (isInsideGsdWorktree(rawBasePath)) return local;
|
||||
if (isInsideSfWorktree(rawBasePath)) return local;
|
||||
|
||||
// Resolve symlinks so path comparisons work correctly across platforms
|
||||
// (e.g. macOS /var → /private/var). Use rawBasePath as fallback if not resolvable.
|
||||
|
|
@ -378,7 +378,7 @@ function probeGsdRoot(rawBasePath: string): string {
|
|||
}
|
||||
|
||||
// Also check the resolved path for the worktree pattern (macOS /tmp → /private/tmp)
|
||||
if (basePath !== rawBasePath && isInsideGsdWorktree(basePath)) return local;
|
||||
if (basePath !== rawBasePath && isInsideSfWorktree(basePath)) return local;
|
||||
|
||||
// 2. Git root anchor — used as both probe target and walk-up boundary
|
||||
// Only walk if we're inside a git project — prevents escaping into
|
||||
|
|
@ -437,14 +437,10 @@ export function resolveSfRootFile(
|
|||
return canonical;
|
||||
}
|
||||
|
||||
export const resolveGsdRootFile = resolveSfRootFile;
|
||||
|
||||
export function relSfRootFile(key: SFRootFileKey): string {
|
||||
return `.sf/${SF_ROOT_FILES[key]}`;
|
||||
}
|
||||
|
||||
export const relGsdRootFile = relSfRootFile;
|
||||
|
||||
/**
|
||||
* Resolve the full path to a milestone directory.
|
||||
* Returns null if the milestone doesn't exist.
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ function resolveExtensionDir(): string {
|
|||
|
||||
// Fallback: user-local agent directory
|
||||
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||
const agentGsdDir = join(sfHome, "agent", "extensions", "sf");
|
||||
if (existsSync(join(agentGsdDir, "prompts"))) return agentGsdDir;
|
||||
const agentSfDir = join(sfHome, "agent", "extensions", "sf");
|
||||
if (existsSync(join(agentSfDir, "prompts"))) return agentSfDir;
|
||||
|
||||
// Last resort: return the module dir (warmCache will silently handle the miss)
|
||||
return moduleDir;
|
||||
|
|
|
|||
|
|
@ -12,12 +12,22 @@ Dispatch ALL slices simultaneously using the `subagent` tool in **parallel mode*
|
|||
|
||||
## Execution Protocol
|
||||
|
||||
1. Call `subagent` with `tasks: [...]` containing one entry per slice below
|
||||
1. Call `subagent` exactly once with the JSON payload below
|
||||
2. Wait for ALL subagents to complete
|
||||
3. Verify each slice's RESEARCH file was written (check the `.sf/{{mid}}/` directory)
|
||||
4. If any subagent failed to write its RESEARCH file, re-run it individually
|
||||
5. Report which slices completed research and which (if any) failed
|
||||
|
||||
## Required `subagent` Call Payload
|
||||
|
||||
Use this exact payload for the `subagent` tool. Do not invent agent names. Do not use legacy executor aliases.
|
||||
|
||||
```json
|
||||
{{subagentCall}}
|
||||
```
|
||||
|
||||
## Subagent Prompts
|
||||
|
||||
The same task payloads are expanded below for readability.
|
||||
|
||||
{{subagentPrompts}}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function isInheritedRepo(basePath: string): boolean {
|
|||
|
||||
// The git root is a proper ancestor. Check whether it already has .sf
|
||||
// (i.e. the parent project was initialised with SF).
|
||||
if (isProjectGsd(join(root, ".sf"))) return false;
|
||||
if (isProjectSf(join(root, ".sf"))) return false;
|
||||
|
||||
// Walk up from basePath's parent to the git root checking for .sf.
|
||||
// Start at dirname(normalizedBase), NOT normalizedBase itself — finding
|
||||
|
|
@ -152,7 +152,7 @@ export function isInheritedRepo(basePath: string): boolean {
|
|||
// says nothing about whether the git repo is inherited from an ancestor.
|
||||
let dir = dirname(normalizedBase);
|
||||
while (dir !== normalizedRoot && dir !== dirname(dir)) {
|
||||
if (isProjectGsd(join(dir, ".sf"))) return false;
|
||||
if (isProjectSf(join(dir, ".sf"))) return false;
|
||||
dir = dirname(dir);
|
||||
}
|
||||
|
||||
|
|
@ -174,23 +174,23 @@ export function isInheritedRepo(basePath: string): boolean {
|
|||
* Treating it as a project `.sf` would cause isInheritedRepo() to wrongly
|
||||
* conclude that subdirectories are part of the home "project" (#2393).
|
||||
*/
|
||||
function isProjectGsd(sfPath: string): boolean {
|
||||
function isProjectSf(sfPath: string): boolean {
|
||||
if (!existsSync(sfPath)) return false;
|
||||
|
||||
try {
|
||||
const stat = lstatSync(sfPath);
|
||||
|
||||
// Symlinks are always project .sf (created by ensureGsdSymlink).
|
||||
// Symlinks are always project .sf (created by ensureSfSymlink).
|
||||
if (stat.isSymbolicLink()) return true;
|
||||
|
||||
// For real directories, check that this isn't the global SF home.
|
||||
// Recompute sfHome dynamically so env overrides (SF_HOME) are
|
||||
// picked up at call time, not just at module load time.
|
||||
if (stat.isDirectory()) {
|
||||
const currentGsdHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||
const normalizedGsdPath = canonicalizeExistingPath(sfPath);
|
||||
const normalizedGsdHome = canonicalizeExistingPath(currentGsdHome);
|
||||
if (normalizedGsdPath === normalizedGsdHome) return false;
|
||||
const currentSfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||
const normalizedSfPath = canonicalizeExistingPath(sfPath);
|
||||
const normalizedSfHome = canonicalizeExistingPath(currentSfHome);
|
||||
if (normalizedSfPath === normalizedSfHome) return false;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -337,7 +337,7 @@ export function repoIdentity(basePath: string): string {
|
|||
* Returns `$SF_STATE_DIR/projects/<hash>` if `SF_STATE_DIR` is set,
|
||||
* otherwise `~/.sf/projects/<hash>`.
|
||||
*/
|
||||
export function externalGsdRoot(basePath: string): string {
|
||||
export function externalSfRoot(basePath: string): string {
|
||||
const base = process.env.SF_STATE_DIR || sfHome;
|
||||
return join(base, "projects", repoIdentity(basePath));
|
||||
}
|
||||
|
|
@ -363,12 +363,12 @@ export function externalProjectsRoot(): string {
|
|||
* directory, making tracked planning files appear deleted.
|
||||
*
|
||||
* This helper scans the project root for entries matching `.sf <digits>` and
|
||||
* removes them. It is called early in `ensureGsdSymlink()` so that the
|
||||
* removes them. It is called early in `ensureSfSymlink()` so that the
|
||||
* canonical `.sf` path is always the one in use.
|
||||
*/
|
||||
const SF_NUMBERED_VARIANT_RE = /^\.sf \d+$/;
|
||||
|
||||
export function cleanNumberedGsdVariants(projectPath: string): string[] {
|
||||
export function cleanNumberedSfVariants(projectPath: string): string[] {
|
||||
const removed: string[] = [];
|
||||
try {
|
||||
const entries = readdirSync(projectPath);
|
||||
|
|
@ -401,7 +401,7 @@ export function cleanNumberedGsdVariants(projectPath: string): string[] {
|
|||
* The marker is gitignored by ensureGitignore(). Non-fatal: failure to write
|
||||
* the marker must never block project setup.
|
||||
*/
|
||||
function writeGsdIdMarker(projectPath: string, identity: string): void {
|
||||
function writeSfIdMarker(projectPath: string, identity: string): void {
|
||||
try {
|
||||
const markerPath = join(projectPath, ".sf-id");
|
||||
// Only write if content differs to avoid unnecessary disk writes.
|
||||
|
|
@ -422,7 +422,7 @@ function writeGsdIdMarker(projectPath: string, identity: string): void {
|
|||
* Read the `.sf-id` marker from the project root.
|
||||
* Returns the identity hash, or null if the marker doesn't exist or is unreadable.
|
||||
*/
|
||||
function readGsdIdMarker(projectPath: string): string | null {
|
||||
function readSfIdMarker(projectPath: string): string | null {
|
||||
try {
|
||||
const markerPath = join(projectPath, ".sf-id");
|
||||
if (!existsSync(markerPath)) return null;
|
||||
|
|
@ -462,7 +462,7 @@ export function hasExternalProjectState(externalPath: string): boolean {
|
|||
* Returns the resolved external path (may differ from the computed identity).
|
||||
*/
|
||||
function resolveExternalPathWithRecovery(projectPath: string): string {
|
||||
const computedPath = externalGsdRoot(projectPath);
|
||||
const computedPath = externalSfRoot(projectPath);
|
||||
const computedId = repoIdentity(projectPath);
|
||||
|
||||
// Check if computed path already has state — fast path, no recovery needed.
|
||||
|
|
@ -471,7 +471,7 @@ function resolveExternalPathWithRecovery(projectPath: string): string {
|
|||
}
|
||||
|
||||
// Check for .sf-id marker from a previous location.
|
||||
const markerId = readGsdIdMarker(projectPath);
|
||||
const markerId = readSfIdMarker(projectPath);
|
||||
if (markerId && markerId !== computedId) {
|
||||
// The marker points to a different identity — the repo was likely moved.
|
||||
const base = process.env.SF_STATE_DIR || sfHome;
|
||||
|
|
@ -528,20 +528,20 @@ function resolveExternalPathWithRecovery(projectPath: string): string {
|
|||
*
|
||||
* Returns the resolved external path.
|
||||
*/
|
||||
export function ensureGsdSymlink(projectPath: string): string {
|
||||
const result = ensureGsdSymlinkCore(projectPath);
|
||||
export function ensureSfSymlink(projectPath: string): string {
|
||||
const result = ensureSfSymlinkCore(projectPath);
|
||||
|
||||
// Write .sf-id marker so future relocations can recover this state (#2750).
|
||||
// Only write for the project root (not subdirectories or worktrees that
|
||||
// delegate to a parent .sf).
|
||||
if (!isInsideWorktree(projectPath)) {
|
||||
writeGsdIdMarker(projectPath, repoIdentity(projectPath));
|
||||
writeSfIdMarker(projectPath, repoIdentity(projectPath));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureGsdSymlinkCore(projectPath: string): string {
|
||||
function ensureSfSymlinkCore(projectPath: string): string {
|
||||
const externalPath = resolveExternalPathWithRecovery(projectPath);
|
||||
const localSf = join(projectPath, ".sf");
|
||||
const inWorktree = isInsideWorktree(projectPath);
|
||||
|
|
@ -566,14 +566,14 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|||
const normalizedProject = canonicalizeExistingPath(projectPath);
|
||||
const normalizedRoot = canonicalizeExistingPath(gitRoot);
|
||||
if (normalizedProject !== normalizedRoot) {
|
||||
const rootGsd = join(gitRoot, ".sf");
|
||||
if (existsSync(rootGsd)) {
|
||||
const rootSf = join(gitRoot, ".sf");
|
||||
if (existsSync(rootSf)) {
|
||||
try {
|
||||
const rootStat = lstatSync(rootGsd);
|
||||
const rootStat = lstatSync(rootSf);
|
||||
if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
|
||||
return rootStat.isSymbolicLink()
|
||||
? realpathSync(rootGsd)
|
||||
: rootGsd;
|
||||
? realpathSync(rootSf)
|
||||
: rootSf;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to normal logic if we can't stat root .sf
|
||||
|
|
@ -587,7 +587,7 @@ function ensureGsdSymlinkCore(projectPath: string): string {
|
|||
|
||||
// Clean up macOS numbered collision variants (.sf 2, .sf 3, etc.) before
|
||||
// any existence checks — otherwise they accumulate and confuse state (#2205).
|
||||
cleanNumberedGsdVariants(projectPath);
|
||||
cleanNumberedSfVariants(projectPath);
|
||||
|
||||
// Ensure external directory exists
|
||||
mkdirSync(externalPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
} from "@singularity-forge/pi-coding-agent";
|
||||
|
||||
import { isAutoActive } from "./auto.js";
|
||||
import { isGsdGitignored } from "./gitignore.js";
|
||||
import { isSfGitignored } from "./gitignore.js";
|
||||
import { buildExistingMilestonesContext } from "./guided-flow-queue.js";
|
||||
import { getParkedReason } from "./milestone-actions.js";
|
||||
import { findMilestoneIds } from "./milestone-ids.js";
|
||||
|
|
@ -69,7 +69,7 @@ export async function handleRethink(
|
|||
state,
|
||||
);
|
||||
|
||||
const commitInstruction = isGsdGitignored(basePath)
|
||||
const commitInstruction = isSfGitignored(basePath)
|
||||
? "Do not commit planning artifacts — .sf/ is gitignored in this project."
|
||||
: 'After changes, run `git add .sf/ && git commit -m "docs(sf): rethink milestone plan"` to persist (rethink runs interactively outside auto-mode, so no system auto-commit)';
|
||||
|
||||
|
|
|
|||
|
|
@ -23,15 +23,22 @@ const MAX_ID_LENGTH = 64;
|
|||
|
||||
export class UnsafeIdError extends TypeError {
|
||||
constructor(
|
||||
public readonly fieldName: string,
|
||||
public readonly reason: string,
|
||||
public readonly value: string,
|
||||
fieldName: string,
|
||||
reason: string,
|
||||
value: string,
|
||||
) {
|
||||
super(
|
||||
`${fieldName} is unsafe: ${reason} (got ${JSON.stringify(value).slice(0, 80)})`,
|
||||
);
|
||||
this.fieldName = fieldName;
|
||||
this.reason = reason;
|
||||
this.value = value;
|
||||
this.name = "UnsafeIdError";
|
||||
}
|
||||
|
||||
public readonly fieldName: string;
|
||||
public readonly reason: string;
|
||||
public readonly value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// GSD2 + skill-manifest — per-unit-type skill allowlist resolver (RFC #4779)
|
||||
// SF2 + skill-manifest — per-unit-type skill allowlist resolver (RFC #4779)
|
||||
//
|
||||
// Each auto-mode unit type can declare which skills are relevant to it. This
|
||||
// trims the set of skills considered for activation in the per-unit prompt,
|
||||
|
|
@ -168,7 +168,7 @@ export function warnIfManifestHasMissingSkills(
|
|||
): void {
|
||||
// Strict mode is intentionally opt-in via exactly "1"; values like "0" or
|
||||
// "false" must preserve the normal silent manifest behavior.
|
||||
if (process.env.GSD_SKILL_MANIFEST_STRICT !== "1") return;
|
||||
if (process.env.SF_SKILL_MANIFEST_STRICT !== "1") return;
|
||||
const allowlist = resolveSkillManifest(unitType);
|
||||
if (!allowlist) return;
|
||||
for (const name of allowlist) {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ Before installing, ensure the skill follows sf naming:
|
|||
|
||||
- Lowercase kebab-case directory name.
|
||||
- Match the directory name exactly to the `name:` field in frontmatter.
|
||||
- No prefixes like `dr-`, `ace-`, `gsd-` — strip them. (`dr-spec-first-tdd` → `spec-first-tdd`.)
|
||||
- No prefixes like `dr-`, `ace-` — strip them. (`dr-spec-first-tdd` → `spec-first-tdd`.)
|
||||
- See [`creating-skills`](../creating-skills/SKILL.md) for the full convention.
|
||||
|
||||
## How to Acquire
|
||||
|
|
@ -136,7 +136,7 @@ rsync -av -e ssh \
|
|||
|
||||
After fetching, **adapt for sf**:
|
||||
|
||||
- Strip foreign prefixes (`dr-`, `ace-`, `gsd-`, `letta-`).
|
||||
- Strip foreign prefixes (`dr-`, `ace-`, `letta-`).
|
||||
- Replace foreign tooling references (Letta MCP tool calls, claude-flow CLIs) with sf-native equivalents (`rg`, `npm test`, `sf_*` tools, `advisory-partner` skill, etc.).
|
||||
- Drop bootstrap gates that don't apply (`onboarding()`, `IN_NIX_SHELL`, etc.).
|
||||
- Cite sf doctrine: `AGENTS.md`, `docs/SPEC_FIRST_TDD.md`, the relevant sister skill.
|
||||
|
|
@ -176,7 +176,7 @@ User asks: "Can you help me test my React app's UI?"
|
|||
- **Read every script before executing it.** No exceptions, even from trusted sources.
|
||||
- **Don't `curl | bash`** unless the user has personally inspected and approved the URL.
|
||||
- **Untrusted sources require explicit user approval** before download.
|
||||
- **Strip foreign prefixes** when porting (`dr-`, `ace-`, `gsd-`, `letta-`).
|
||||
- **Strip foreign prefixes** when porting (`dr-`, `ace-`, `letta-`).
|
||||
- **Adapt tooling references** to sf-native equivalents.
|
||||
- **Cite sf doctrine** — link `AGENTS.md` and `docs/SPEC_FIRST_TDD.md` rather than restating their rules.
|
||||
- **Don't overwrite an existing sf skill** without diffing first; if names collide, decide whether to merge, supersede, or rename.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ The job: reduce ambiguity that would otherwise cause bad plans, wrong tests, or
|
|||
- A milestone goal is "make it better" or "robust" or "fast" — vague verbs that aren't testable.
|
||||
- A slice plan is being drafted but key boundaries are unstated.
|
||||
- A change touches a security/auth surface and the threat model isn't named.
|
||||
- An upstream port (pi-mono / gsd-2) leaves architectural intent ambiguous after reading the commit.
|
||||
- An upstream port (pi-mono legacy port) leaves architectural intent ambiguous after reading the commit.
|
||||
|
||||
If the request is concrete and the consumer is obvious, skip this skill — go straight to `brainstorming` or `spec-first-tdd`.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Look for:
|
|||
| Decay type | Symptoms | Fix |
|
||||
|---|---|---|
|
||||
| **Bloat** | `.sf/CODEBASE.md` is 5x its useful size; same fact stated 4 times. | Compress: keep one canonical statement, delete the rest. |
|
||||
| **Stale** | A file references `extensions/gsd/` (renamed to `extensions/sf/`). | Update; or, if the fact is now self-evident from the code, delete. |
|
||||
| **Stale** | A file references `extensions/old-extension/` (renamed to `extensions/sf/`). | Update; or, if the fact is now self-evident from the code, delete. |
|
||||
| **Contradiction** | `.sf/DECISIONS.md` says "use bun" but `AGENTS.md` says "npm canonical". | Find the canonical source (usually `AGENTS.md` for sf), fix the other. |
|
||||
| **Orphaned** | A reference points to a file that was deleted. | Delete the reference, or restore the file if it should still exist. |
|
||||
| **Skill overlap** | Two skills try to do the same job. | Either merge them or scope each to its distinct sub-case. |
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ porting-from-upstream/
|
|||
├── SKILL.md (overview + which-upstream selection)
|
||||
└── references/
|
||||
├── pi-mono.md (cherry-pick patterns)
|
||||
├── gsd-2.md (manual port + naming translation)
|
||||
├── legacy-port.md (manual port + naming translation)
|
||||
└── bunker.md (skill harvest from remote host)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ git worktree remove ../singularity-forge-my-feature
|
|||
- Another agent (sf auto-loop, another Claude session, a teammate) is working in the current directory.
|
||||
- A long-running build or test is in flight in one terminal and you need a parallel branch.
|
||||
- You're exploring a refactor that you may abandon — keep main clean.
|
||||
- You need to apply an upstream cherry-pick from `pi-mono` while a separate `gsd-2` port is in progress.
|
||||
- You need to apply an upstream cherry-pick from `pi-mono` while a separate legacy port is in progress.
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
|
|
|
|||
|
|
@ -302,9 +302,9 @@ function filterConflictingSlices(
|
|||
|
||||
/**
|
||||
* Resolve the SF CLI binary path.
|
||||
* Same logic as parallel-orchestrator.ts resolveGsdBin().
|
||||
* Same logic as parallel-orchestrator.ts resolveSfBin().
|
||||
*/
|
||||
function resolveGsdBin(): string | null {
|
||||
function resolveSfBin(): string | null {
|
||||
if (process.env.SF_BIN_PATH && existsSync(process.env.SF_BIN_PATH)) {
|
||||
return process.env.SF_BIN_PATH;
|
||||
}
|
||||
|
|
@ -341,7 +341,7 @@ function spawnSliceWorker(
|
|||
if (!worker) return false;
|
||||
if (worker.process) return true;
|
||||
|
||||
const binPath = resolveGsdBin();
|
||||
const binPath = resolveSfBin();
|
||||
if (!binPath) return false;
|
||||
|
||||
let child: ChildProcess;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { findMilestoneIds } from "./milestone-ids.js";
|
||||
import { getVisionAlignmentBlockingIssue } from "./milestone-quality.js";
|
||||
import { isTerminalMilestoneSummaryContent } from "./milestone-summary-classifier.js";
|
||||
import { nativeBatchParseGsdFiles } from "./native-parser-bridge.js";
|
||||
import { nativeBatchParseSfFiles } from "./native-parser-bridge.js";
|
||||
import { parsePlan, parseRoadmap } from "./parsers-legacy.js";
|
||||
import {
|
||||
resolveMilestoneFile,
|
||||
|
|
@ -1438,7 +1438,7 @@ export async function _deriveStateImpl(basePath: string): Promise<SFState> {
|
|||
// Filesystem fallback: used when deriveStateFromDb() is not available
|
||||
// (pre-migration projects). The DB-backed path is preferred when available
|
||||
// — see deriveStateFromDb() above.
|
||||
const batchFiles = nativeBatchParseGsdFiles(sfDir);
|
||||
const batchFiles = nativeBatchParseSfFiles(sfDir);
|
||||
if (batchFiles) {
|
||||
for (const f of batchFiles) {
|
||||
const absPath = resolve(sfDir, f.path);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ function makeTempDir(prefix: string): string {
|
|||
|
||||
test("resolvePreferredModelConfig synthesizes heavy routing ceiling when models section is absent", () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalGsdHome = process.env.SF_HOME;
|
||||
const originalSfHome = process.env.SF_HOME;
|
||||
const tempProject = makeTempDir("sf-routing-project-");
|
||||
const tempGsdHome = makeTempDir("sf-routing-home-");
|
||||
const tempSfHome = makeTempDir("sf-routing-home-");
|
||||
|
||||
try {
|
||||
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
||||
|
|
@ -45,7 +45,7 @@ test("resolvePreferredModelConfig synthesizes heavy routing ceiling when models
|
|||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
process.env.SF_HOME = tempGsdHome;
|
||||
process.env.SF_HOME = tempSfHome;
|
||||
process.chdir(tempProject);
|
||||
|
||||
const config = resolvePreferredModelConfig("plan-slice", {
|
||||
|
|
@ -59,18 +59,18 @@ test("resolvePreferredModelConfig synthesizes heavy routing ceiling when models
|
|||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalGsdHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalGsdHome;
|
||||
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalSfHome;
|
||||
rmSync(tempProject, { recursive: true, force: true });
|
||||
rmSync(tempGsdHome, { recursive: true, force: true });
|
||||
rmSync(tempSfHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolvePreferredModelConfig falls back to auto start model when heavy tier is absent", () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalGsdHome = process.env.SF_HOME;
|
||||
const originalSfHome = process.env.SF_HOME;
|
||||
const tempProject = makeTempDir("sf-routing-project-");
|
||||
const tempGsdHome = makeTempDir("sf-routing-home-");
|
||||
const tempSfHome = makeTempDir("sf-routing-home-");
|
||||
|
||||
try {
|
||||
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
||||
|
|
@ -87,7 +87,7 @@ test("resolvePreferredModelConfig falls back to auto start model when heavy tier
|
|||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
process.env.SF_HOME = tempGsdHome;
|
||||
process.env.SF_HOME = tempSfHome;
|
||||
process.chdir(tempProject);
|
||||
|
||||
const config = resolvePreferredModelConfig("execute-task", {
|
||||
|
|
@ -101,18 +101,18 @@ test("resolvePreferredModelConfig falls back to auto start model when heavy tier
|
|||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalGsdHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalGsdHome;
|
||||
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalSfHome;
|
||||
rmSync(tempProject, { recursive: true, force: true });
|
||||
rmSync(tempGsdHome, { recursive: true, force: true });
|
||||
rmSync(tempSfHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalGsdHome = process.env.SF_HOME;
|
||||
const originalSfHome = process.env.SF_HOME;
|
||||
const tempProject = makeTempDir("sf-routing-project-");
|
||||
const tempGsdHome = makeTempDir("sf-routing-home-");
|
||||
const tempSfHome = makeTempDir("sf-routing-home-");
|
||||
|
||||
try {
|
||||
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
||||
|
|
@ -130,7 +130,7 @@ test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", (
|
|||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
process.env.SF_HOME = tempGsdHome;
|
||||
process.env.SF_HOME = tempSfHome;
|
||||
process.chdir(tempProject);
|
||||
|
||||
const config = resolvePreferredModelConfig("plan-slice", {
|
||||
|
|
@ -144,23 +144,23 @@ test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", (
|
|||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalGsdHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalGsdHome;
|
||||
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalSfHome;
|
||||
rmSync(tempProject, { recursive: true, force: true });
|
||||
rmSync(tempGsdHome, { recursive: true, force: true });
|
||||
rmSync(tempSfHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("selectAndApplyModel does not let learned routing override an explicit execution model", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalGsdHome = process.env.SF_HOME;
|
||||
const originalSfHome = process.env.SF_HOME;
|
||||
const tempProject = makeTempDir("sf-routing-project-");
|
||||
const tempGsdHome = makeTempDir("sf-routing-home-");
|
||||
const tempSfHome = makeTempDir("sf-routing-home-");
|
||||
|
||||
try {
|
||||
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempGsdHome, "preferences.md"),
|
||||
join(tempSfHome, "preferences.md"),
|
||||
[
|
||||
"---",
|
||||
"version: 1",
|
||||
|
|
@ -175,7 +175,7 @@ test("selectAndApplyModel does not let learned routing override an explicit exec
|
|||
["---", "version: 1", "models: {}", "---"].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
process.env.SF_HOME = tempGsdHome;
|
||||
process.env.SF_HOME = tempSfHome;
|
||||
process.chdir(tempProject);
|
||||
|
||||
const availableModels = [
|
||||
|
|
@ -227,10 +227,10 @@ test("selectAndApplyModel does not let learned routing override an explicit exec
|
|||
assert.equal(result.appliedModel?.id, "kimi-for-coding");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalGsdHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalGsdHome;
|
||||
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalSfHome;
|
||||
rmSync(tempProject, { recursive: true, force: true });
|
||||
rmSync(tempGsdHome, { recursive: true, force: true });
|
||||
rmSync(tempSfHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { join } from "node:path";
|
|||
import { afterEach, before, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
getGsdArgumentCompletions,
|
||||
getSfArgumentCompletions,
|
||||
TOP_LEVEL_SUBCOMMANDS,
|
||||
} from "../commands/catalog.ts";
|
||||
|
||||
|
|
@ -116,8 +116,8 @@ describe("workflow catalog registration", () => {
|
|||
assert.match(entry!.desc, /session model/i);
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('m') includes model", () => {
|
||||
const completions = getGsdArgumentCompletions("m");
|
||||
it("getSfArgumentCompletions('m') includes model", () => {
|
||||
const completions = getSfArgumentCompletions("m");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
assert.ok(labels.includes("model"), "should include model completion");
|
||||
});
|
||||
|
|
@ -129,8 +129,8 @@ describe("workflow catalog registration", () => {
|
|||
assert.ok(entry!.desc.includes("run"), "description should mention run");
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('workflow ') returns six subcommands", () => {
|
||||
const completions = getGsdArgumentCompletions("workflow ");
|
||||
it("getSfArgumentCompletions('workflow ') returns six subcommands", () => {
|
||||
const completions = getSfArgumentCompletions("workflow ");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
for (const sub of ["new", "run", "list", "validate", "pause", "resume"]) {
|
||||
assert.ok(labels.includes(sub), `missing completion: ${sub}`);
|
||||
|
|
@ -138,15 +138,15 @@ describe("workflow catalog registration", () => {
|
|||
assert.equal(labels.length, 6, "should have exactly 6 subcommands");
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('workflow r') filters to run and resume", () => {
|
||||
const completions = getGsdArgumentCompletions("workflow r");
|
||||
it("getSfArgumentCompletions('workflow r') filters to run and resume", () => {
|
||||
const completions = getSfArgumentCompletions("workflow r");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
assert.ok(labels.includes("run"), "should include run");
|
||||
assert.ok(labels.includes("resume"), "should include resume");
|
||||
assert.ok(!labels.includes("list"), "should not include list");
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('workflow run ') returns definition names", () => {
|
||||
it("getSfArgumentCompletions('workflow run ') returns definition names", () => {
|
||||
const base = makeTmpBase();
|
||||
writeDefinition(base, "deploy-pipeline", SIMPLE_DEF);
|
||||
writeDefinition(base, "test-suite", SIMPLE_DEF);
|
||||
|
|
@ -154,7 +154,7 @@ describe("workflow catalog registration", () => {
|
|||
// Change cwd so the completion scanner can find `.sf/workflow-defs/`
|
||||
process.chdir(base);
|
||||
|
||||
const completions = getGsdArgumentCompletions("workflow run ");
|
||||
const completions = getSfArgumentCompletions("workflow run ");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
assert.ok(
|
||||
labels.includes("deploy-pipeline"),
|
||||
|
|
@ -163,25 +163,25 @@ describe("workflow catalog registration", () => {
|
|||
assert.ok(labels.includes("test-suite"), "should include test-suite");
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('workflow validate ') returns definition names", () => {
|
||||
it("getSfArgumentCompletions('workflow validate ') returns definition names", () => {
|
||||
const base = makeTmpBase();
|
||||
writeDefinition(base, "my-workflow", SIMPLE_DEF);
|
||||
|
||||
process.chdir(base);
|
||||
|
||||
const completions = getGsdArgumentCompletions("workflow validate ");
|
||||
const completions = getSfArgumentCompletions("workflow validate ");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
assert.ok(labels.includes("my-workflow"), "should include my-workflow");
|
||||
});
|
||||
|
||||
it("getGsdArgumentCompletions('workflow run d') filters by prefix", () => {
|
||||
it("getSfArgumentCompletions('workflow run d') filters by prefix", () => {
|
||||
const base = makeTmpBase();
|
||||
writeDefinition(base, "deploy-pipeline", SIMPLE_DEF);
|
||||
writeDefinition(base, "test-suite", SIMPLE_DEF);
|
||||
|
||||
process.chdir(base);
|
||||
|
||||
const completions = getGsdArgumentCompletions("workflow run d");
|
||||
const completions = getSfArgumentCompletions("workflow run d");
|
||||
const labels = completions.map((c: any) => c.label);
|
||||
assert.ok(
|
||||
labels.includes("deploy-pipeline"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// GSD-2 — #4782 phase 3 batch 3: complete-slice migrated through composer.
|
||||
// SF — #4782 phase 3 batch 3: complete-slice migrated through composer.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
|
@ -18,9 +18,9 @@ import {
|
|||
} from "../sf-db.ts";
|
||||
|
||||
function makeBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-completeslice-composer-"));
|
||||
const base = mkdtempSync(join(tmpdir(), "sf-completeslice-composer-"));
|
||||
mkdirSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"),
|
||||
join(base, ".sf", "milestones", "M001", "slices", "S01", "tasks"),
|
||||
{ recursive: true },
|
||||
);
|
||||
return base;
|
||||
|
|
@ -37,7 +37,7 @@ function cleanup(base: string): void {
|
|||
}
|
||||
|
||||
function seed(base: string, mid: string): void {
|
||||
openDatabase(join(base, ".gsd", "gsd.db"));
|
||||
openDatabase(join(base, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Composer Test",
|
||||
|
|
@ -80,17 +80,17 @@ function seed(base: string, mid: string): void {
|
|||
|
||||
function writeArtifacts(base: string): void {
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
join(base, ".sf", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
"# M001 Roadmap\n## Slices\n- [x] **S01: First** `risk:low` `depends:[]`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01 Plan\n\nSlice plan body.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(
|
||||
base,
|
||||
".gsd",
|
||||
".sf",
|
||||
"milestones",
|
||||
"M001",
|
||||
"slices",
|
||||
|
|
@ -163,11 +163,11 @@ test("#4782 phase 3: buildCompleteSlicePrompt handles missing task summaries gra
|
|||
seed(base, "M001");
|
||||
// Write roadmap + plan but no task summaries
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
join(base, ".sf", "milestones", "M001", "M001-ROADMAP.md"),
|
||||
"# M001 Roadmap\n## Slices\n- [x] **S01: First** `risk:low` `depends:[]`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
||||
"# S01 Plan\n",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ test("copyPlanningArtifacts skips when source and destination .sf resolve to the
|
|||
|
||||
const fnBody = src.slice(fnIdx, fnIdx + 2400);
|
||||
|
||||
const guardIdx = fnBody.indexOf("if (isSamePath(srcGsd, dstGsd)) return;");
|
||||
const guardIdx = fnBody.indexOf("if (isSamePath(srcSf, dstSf)) return;");
|
||||
const copyIdx = fnBody.indexOf(
|
||||
'safeCopyRecursive(join(srcGsd, "milestones")',
|
||||
'safeCopyRecursive(join(srcSf, "milestones")',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
writeDebugSummary,
|
||||
} from "../debug-logger.ts";
|
||||
|
||||
function createTempGsdDir(): string {
|
||||
function createTempSfDir(): string {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "sf-debug-test-"));
|
||||
mkdirSync(join(tmp, ".sf"), { recursive: true });
|
||||
return tmp;
|
||||
|
|
@ -37,7 +37,7 @@ function readLogLines(logPath: string): Record<string, unknown>[] {
|
|||
}
|
||||
|
||||
test("enableDebug creates log file and sets enabled", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
assert.strictEqual(isDebugEnabled(), true);
|
||||
|
|
@ -56,7 +56,7 @@ test("enableDebug creates log file and sets enabled", () => {
|
|||
});
|
||||
|
||||
test("debugLog writes JSONL events", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
debugLog("test-event", { foo: "bar", num: 42 });
|
||||
|
|
@ -82,7 +82,7 @@ test("debugLog is no-op when disabled", () => {
|
|||
});
|
||||
|
||||
test("debugTime measures elapsed time", async () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
const stop = debugTime("timed-op");
|
||||
|
|
@ -111,7 +111,7 @@ test("debugTime returns no-op when disabled", () => {
|
|||
});
|
||||
|
||||
test("debugCount increments counters", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
debugCount("dispatches");
|
||||
|
|
@ -128,7 +128,7 @@ test("debugCount increments counters", () => {
|
|||
});
|
||||
|
||||
test("debugPeak tracks max values", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
debugPeak("ttsrPeakBuffer", 100);
|
||||
|
|
@ -143,7 +143,7 @@ test("debugPeak tracks max values", () => {
|
|||
});
|
||||
|
||||
test("writeDebugSummary includes all counters and disables debug", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
debugCount("deriveStateCalls", 10);
|
||||
|
|
@ -171,7 +171,7 @@ test("writeDebugSummary includes all counters and disables debug", () => {
|
|||
});
|
||||
|
||||
test("auto-prunes old debug logs", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
const debugDir = join(tmp, ".sf", "debug");
|
||||
mkdirSync(debugDir, { recursive: true });
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ test("auto-prunes old debug logs", () => {
|
|||
});
|
||||
|
||||
test("disableDebug returns log path", () => {
|
||||
const tmp = createTempGsdDir();
|
||||
const tmp = createTempSfDir();
|
||||
enableDebug(tmp);
|
||||
|
||||
const logPath = getDebugLogPath();
|
||||
|
|
|
|||
|
|
@ -467,14 +467,14 @@ import { runPostExecutionChecks } from "./post-execution-checks.ts";
|
|||
|
||||
test("handles large number of files without timeout", () => {
|
||||
// Use all available SF source files to stress test
|
||||
const allGsdFiles = REAL_SF_FILES.map((f) => join(SF_SRC_DIR, f));
|
||||
const allSfFiles = REAL_SF_FILES.map((f) => join(SF_SRC_DIR, f));
|
||||
|
||||
const task = createTask({
|
||||
id: "T01",
|
||||
title: "Large refactor touching many files",
|
||||
status: "complete",
|
||||
key_files: allGsdFiles,
|
||||
files: allGsdFiles,
|
||||
key_files: allSfFiles,
|
||||
files: allSfFiles,
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ test('withFileLockSync: onLocked="skip" runs callback unlocked on ELOCKED', () =
|
|||
}
|
||||
|
||||
const lockfile = require("proper-lockfile");
|
||||
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-file-lock-test-"));
|
||||
const filePath = join(dir, "locked.jsonl");
|
||||
writeFileSync(filePath, "{}\n", "utf-8");
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ test('withFileLock: onLocked="skip" runs callback unlocked on ELOCKED', async ()
|
|||
}
|
||||
|
||||
const lockfile = require("proper-lockfile");
|
||||
const dir = mkdtempSync(join(tmpdir(), "gsd-file-lock-test-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-file-lock-test-"));
|
||||
const filePath = join(dir, "locked.jsonl");
|
||||
writeFileSync(filePath, "{}\n", "utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { join } from "node:path";
|
|||
import { afterEach, beforeEach, describe, test } from "node:test";
|
||||
|
||||
import { repairMissingSfSymlinkForHeadless } from "../../../../headless.ts";
|
||||
import { externalGsdRoot } from "../repo-identity.ts";
|
||||
import { externalSfRoot } from "../repo-identity.ts";
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, {
|
||||
|
|
@ -52,7 +52,7 @@ describe("headless project repair", () => {
|
|||
});
|
||||
|
||||
test("re-links .sf when matching external project state already exists", () => {
|
||||
const externalPath = externalGsdRoot(base);
|
||||
const externalPath = externalSfRoot(base);
|
||||
mkdirSync(join(externalPath, "milestones"), { recursive: true });
|
||||
|
||||
const repairedPath = repairMissingSfSymlinkForHeadless(base);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function createTempRepo(): string {
|
|||
return dir;
|
||||
}
|
||||
|
||||
function createTempRepoWithExternalGsd(): {
|
||||
function createTempRepoWithExternalSf(): {
|
||||
repo: string;
|
||||
externalState: string;
|
||||
} {
|
||||
|
|
@ -137,8 +137,8 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
|
|||
return d;
|
||||
}
|
||||
|
||||
function freshRepoWithExternalGsd(): { repo: string; externalState: string } {
|
||||
const { repo, externalState } = createTempRepoWithExternalGsd();
|
||||
function freshRepoWithExternalSf(): { repo: string; externalState: string } {
|
||||
const { repo, externalState } = createTempRepoWithExternalSf();
|
||||
tempDirs.push(repo, externalState);
|
||||
return { repo, externalState };
|
||||
}
|
||||
|
|
@ -969,7 +969,7 @@ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
|
|||
});
|
||||
|
||||
test("#2156: mergeMilestoneToMain removes external-state worktrees using the milestone branch name", () => {
|
||||
const { repo, externalState } = freshRepoWithExternalGsd();
|
||||
const { repo, externalState } = freshRepoWithExternalSf();
|
||||
const wtPath = createAutoWorktree(repo, "M215");
|
||||
|
||||
addSliceToMilestone(repo, wtPath, "M215", "S01", "External cleanup", [
|
||||
|
|
|
|||
|
|
@ -55,10 +55,10 @@ describe("doctor false-positives (#3105)", async () => {
|
|||
|
||||
// Create a worktree directory that only has .sf/doctor-history.jsonl
|
||||
const wtDir = join(sf, "worktrees", "M042");
|
||||
const wtGsdDir = join(wtDir, ".sf");
|
||||
mkdirSync(wtGsdDir, { recursive: true });
|
||||
const wtSfDir = join(wtDir, ".sf");
|
||||
mkdirSync(wtSfDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(wtGsdDir, "doctor-history.jsonl"),
|
||||
join(wtSfDir, "doctor-history.jsonl"),
|
||||
'{"ts":"2026-01-01","ok":true}\n',
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -760,13 +760,13 @@ describe("doctor-git", async () => {
|
|||
|
||||
// Move .sf to an external location and replace with a symlink.
|
||||
// This simulates the ~/.sf/projects/<hash> layout where .sf is a symlink.
|
||||
const externalGsd = join(
|
||||
const externalSf = join(
|
||||
realpathSync(mkdtempSync(join(tmpdir(), "doc-git-symlink-"))),
|
||||
"sf-data",
|
||||
);
|
||||
cleanups.push(externalGsd);
|
||||
renameSync(join(dir, ".sf"), externalGsd);
|
||||
symlinkSync(externalGsd, join(dir, ".sf"));
|
||||
cleanups.push(externalSf);
|
||||
renameSync(join(dir, ".sf"), externalSf);
|
||||
symlinkSync(externalSf, join(dir, ".sf"));
|
||||
|
||||
// Create a real registered worktree under the (now symlinked) .sf/worktrees/
|
||||
mkdirSync(join(dir, ".sf", "worktrees"), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -343,9 +343,10 @@ None
|
|||
"fix adds patterns",
|
||||
);
|
||||
|
||||
// Verify .sf entry was added (external state symlink)
|
||||
const content = readFileSync(join(dir, ".gitignore"), "utf-8");
|
||||
assert.ok(content.includes(".sf"), "gitignore now has .sf entry");
|
||||
assert.doesNotThrow(
|
||||
() => run("git check-ignore -q .sf", dir),
|
||||
"git now ignores .sf after fix",
|
||||
);
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
|
@ -377,38 +378,42 @@ node_modules/
|
|||
} else {
|
||||
}
|
||||
|
||||
// ─── Test 8b: Symlinked .gsd without .gitignore entry (#4423) ─────
|
||||
// ─── Test 8b: Symlinked .sf without .gitignore entry (#4423) ─────
|
||||
if (process.platform !== "win32") {
|
||||
test("symlinked_gsd_unignored", async () => {
|
||||
test("symlinked_sf_unignored", async () => {
|
||||
const dir = createGitProject();
|
||||
cleanups.push(dir);
|
||||
|
||||
// Create .gsd as a symlink to an external directory (standard external
|
||||
// state layout), and write a .gitignore that does NOT list .gsd.
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "gsd-external-doctor-"));
|
||||
cleanups.push(externalGsd);
|
||||
writeFileSync(join(externalGsd, "STATE.md"), "# State\n");
|
||||
symlinkSync(externalGsd, join(dir, ".gsd"));
|
||||
// Create .sf as a symlink to an external directory (standard external
|
||||
// state layout), and write a .gitignore that does NOT list .sf.
|
||||
const externalSf = mkdtempSync(join(tmpdir(), "sf-external-doctor-"));
|
||||
cleanups.push(externalSf);
|
||||
writeFileSync(join(externalSf, "STATE.md"), "# State\n");
|
||||
symlinkSync(externalSf, join(dir, ".sf"));
|
||||
|
||||
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
|
||||
|
||||
const detect = await runSFDoctor(dir);
|
||||
const symlinkIssues = detect.issues.filter(
|
||||
(i: any) => i.code === "symlinked_gsd_unignored",
|
||||
(i: any) => i.code === "symlinked_sf_unignored",
|
||||
);
|
||||
assert.ok(
|
||||
symlinkIssues.length > 0,
|
||||
"detects symlinked .gsd without gitignore entry",
|
||||
"detects symlinked .sf without gitignore entry",
|
||||
);
|
||||
|
||||
const fixed = await runSFDoctor(dir, { fix: true });
|
||||
assert.ok(
|
||||
fixed.fixesApplied.some((f: any) => f.includes(".gitignore")),
|
||||
"fix updates .gitignore",
|
||||
fixed.fixesApplied.some((f: any) =>
|
||||
f.includes("added missing SF runtime patterns"),
|
||||
),
|
||||
"fix adds SF runtime ignore patterns",
|
||||
);
|
||||
|
||||
const content = readFileSync(join(dir, ".gitignore"), "utf-8");
|
||||
assert.ok(/^\.gsd\/?$/m.test(content), "gitignore now has .gsd entry");
|
||||
assert.doesNotThrow(
|
||||
() => run("git check-ignore -q .sf", dir),
|
||||
"git now ignores symlinked .sf after fix",
|
||||
);
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,8 +143,8 @@ describe("doctor", async () => {
|
|||
// ─── Milestone summary detection: missing summary ──────────────────────
|
||||
test("doctor detects missing milestone summary", async () => {
|
||||
const msBase = mkdtempSync(join(tmpdir(), "sf-doctor-ms-test-"));
|
||||
const msGsd = join(msBase, ".sf");
|
||||
const msMDir = join(msGsd, "milestones", "M001");
|
||||
const msSf = join(msBase, ".sf");
|
||||
const msMDir = join(msSf, "milestones", "M001");
|
||||
const msSDir = join(msMDir, "slices", "S01");
|
||||
const msTDir = join(msSDir, "tasks");
|
||||
mkdirSync(msTDir, { recursive: true });
|
||||
|
|
@ -241,8 +241,8 @@ parent: M001
|
|||
// ─── Milestone summary detection: summary present (no false positive) ──
|
||||
test("doctor does NOT flag milestone with summary", async () => {
|
||||
const msBase = mkdtempSync(join(tmpdir(), "sf-doctor-ms-ok-test-"));
|
||||
const msGsd = join(msBase, ".sf");
|
||||
const msMDir = join(msGsd, "milestones", "M001");
|
||||
const msSf = join(msBase, ".sf");
|
||||
const msMDir = join(msSf, "milestones", "M001");
|
||||
const msSDir = join(msMDir, "slices", "S01");
|
||||
const msTDir = join(msSDir, "tasks");
|
||||
mkdirSync(msTDir, { recursive: true });
|
||||
|
|
@ -317,8 +317,8 @@ parent: M001
|
|||
// ─── blocker_discovered_no_replan detection ────────────────────────────
|
||||
test("doctor detects blocker_discovered_no_replan", async () => {
|
||||
const bBase = mkdtempSync(join(tmpdir(), "sf-doctor-blocker-test-"));
|
||||
const bGsd = join(bBase, ".sf");
|
||||
const bMDir = join(bGsd, "milestones", "M001");
|
||||
const bSf = join(bBase, ".sf");
|
||||
const bMDir = join(bSf, "milestones", "M001");
|
||||
const bSDir = join(bMDir, "slices", "S01");
|
||||
const bTDir = join(bSDir, "tasks");
|
||||
mkdirSync(bTDir, { recursive: true });
|
||||
|
|
@ -408,8 +408,8 @@ Discovered an issue.
|
|||
// ─── blocker_discovered with REPLAN.md (no false positive) ─────────────
|
||||
test("doctor does NOT flag blocker when REPLAN.md exists", async () => {
|
||||
const bBase = mkdtempSync(join(tmpdir(), "sf-doctor-blocker-ok-test-"));
|
||||
const bGsd = join(bBase, ".sf");
|
||||
const bMDir = join(bGsd, "milestones", "M001");
|
||||
const bSf = join(bBase, ".sf");
|
||||
const bMDir = join(bSf, "milestones", "M001");
|
||||
const bSDir = join(bMDir, "slices", "S01");
|
||||
const bTDir = join(bSDir, "tasks");
|
||||
mkdirSync(bTDir, { recursive: true });
|
||||
|
|
@ -482,8 +482,8 @@ Discovered an issue.
|
|||
// ─── Must-have verification: all addressed → no issue ─────────────────
|
||||
test("doctor: done task with must-haves all addressed → no issue", async () => {
|
||||
const mhBase = mkdtempSync(join(tmpdir(), "sf-doctor-mh-ok-"));
|
||||
const mhGsd = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhGsd, "milestones", "M001");
|
||||
const mhSf = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhSf, "milestones", "M001");
|
||||
const mhSDir = join(mhMDir, "slices", "S01");
|
||||
const mhTDir = join(mhSDir, "tasks");
|
||||
mkdirSync(mhTDir, { recursive: true });
|
||||
|
|
@ -523,8 +523,8 @@ Discovered an issue.
|
|||
// ─── Must-have verification: not addressed → warning fired ───────────
|
||||
test("doctor: done task with must-haves NOT addressed → warning", async () => {
|
||||
const mhBase = mkdtempSync(join(tmpdir(), "sf-doctor-mh-fail-"));
|
||||
const mhGsd = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhGsd, "milestones", "M001");
|
||||
const mhSf = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhSf, "milestones", "M001");
|
||||
const mhSDir = join(mhMDir, "slices", "S01");
|
||||
const mhTDir = join(mhSDir, "tasks");
|
||||
mkdirSync(mhTDir, { recursive: true });
|
||||
|
|
@ -588,8 +588,8 @@ Discovered an issue.
|
|||
// ─── Must-have verification: no task plan → no issue ─────────────────
|
||||
test("doctor: done task with no task plan file → no issue", async () => {
|
||||
const mhBase = mkdtempSync(join(tmpdir(), "sf-doctor-mh-noplan-"));
|
||||
const mhGsd = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhGsd, "milestones", "M001");
|
||||
const mhSf = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhSf, "milestones", "M001");
|
||||
const mhSDir = join(mhMDir, "slices", "S01");
|
||||
const mhTDir = join(mhSDir, "tasks");
|
||||
mkdirSync(mhTDir, { recursive: true });
|
||||
|
|
@ -623,8 +623,8 @@ Discovered an issue.
|
|||
// ─── Must-have verification: plan exists but no Must-Haves section → no issue
|
||||
test("doctor: done task with plan but no Must-Haves section → no issue", async () => {
|
||||
const mhBase = mkdtempSync(join(tmpdir(), "sf-doctor-mh-nosect-"));
|
||||
const mhGsd = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhGsd, "milestones", "M001");
|
||||
const mhSf = join(mhBase, ".sf");
|
||||
const mhMDir = join(mhSf, "milestones", "M001");
|
||||
const mhSDir = join(mhMDir, "slices", "S01");
|
||||
const mhTDir = join(mhSDir, "tasks");
|
||||
mkdirSync(mhTDir, { recursive: true });
|
||||
|
|
@ -717,8 +717,8 @@ Discovered an issue.
|
|||
// ─── doctor detects delimiter_in_title for milestone ───────────────────
|
||||
test("doctor detects em dash in milestone title", async () => {
|
||||
const dtBase = mkdtempSync(join(tmpdir(), "sf-doctor-dt-test-"));
|
||||
const dtGsd = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtGsd, "milestones", "M001");
|
||||
const dtSf = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtSf, "milestones", "M001");
|
||||
const dtSDir = join(dtMDir, "slices", "S01");
|
||||
const dtTDir = join(dtSDir, "tasks");
|
||||
mkdirSync(dtTDir, { recursive: true });
|
||||
|
|
@ -776,8 +776,8 @@ Discovered an issue.
|
|||
// ─── doctor detects delimiter_in_title for slice ────────────────────────
|
||||
test("doctor detects em dash in slice title", async () => {
|
||||
const dtBase = mkdtempSync(join(tmpdir(), "sf-doctor-dt-slice-"));
|
||||
const dtGsd = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtGsd, "milestones", "M001");
|
||||
const dtSf = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtSf, "milestones", "M001");
|
||||
const dtSDir = join(dtMDir, "slices", "S01");
|
||||
const dtTDir = join(dtSDir, "tasks");
|
||||
mkdirSync(dtTDir, { recursive: true });
|
||||
|
|
@ -823,8 +823,8 @@ Discovered an issue.
|
|||
// ─── doctor does NOT flag clean titles ──────────────────────────────────
|
||||
test("doctor does NOT flag milestone with clean title", async () => {
|
||||
const dtBase = mkdtempSync(join(tmpdir(), "sf-doctor-dt-clean-"));
|
||||
const dtGsd = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtGsd, "milestones", "M001");
|
||||
const dtSf = join(dtBase, ".sf");
|
||||
const dtMDir = join(dtSf, "milestones", "M001");
|
||||
const dtSDir = join(dtMDir, "slices", "S01");
|
||||
const dtTDir = join(dtSDir, "tasks");
|
||||
mkdirSync(dtTDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -457,8 +457,8 @@ describe("feature-branch-lifecycle-integration", async () => {
|
|||
|
||||
// With external state, worktree .sf is a symlink to shared state.
|
||||
// Verify symlink was created (planning files are shared, not copied).
|
||||
const wtGsd = join(wtPath, ".sf");
|
||||
assert.ok(existsSync(wtGsd), "worktree .sf exists (symlink or dir)");
|
||||
const wtSf = join(wtPath, ".sf");
|
||||
assert.ok(existsSync(wtSf), "worktree .sf exists (symlink or dir)");
|
||||
|
||||
// Clean up: chdir back before teardown
|
||||
process.chdir(savedCwd);
|
||||
|
|
|
|||
|
|
@ -1686,13 +1686,13 @@ describe("git-service", async () => {
|
|||
const repo = initTempRepo();
|
||||
|
||||
// Create the real .sf directory outside the repo, then symlink it
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "sf-external-"));
|
||||
mkdirSync(join(externalGsd, "activity"), { recursive: true });
|
||||
writeFileSync(join(externalGsd, "activity", "log.jsonl"), "log data");
|
||||
writeFileSync(join(externalGsd, "STATE.md"), "# State");
|
||||
const externalSf = mkdtempSync(join(tmpdir(), "sf-external-"));
|
||||
mkdirSync(join(externalSf, "activity"), { recursive: true });
|
||||
writeFileSync(join(externalSf, "activity", "log.jsonl"), "log data");
|
||||
writeFileSync(join(externalSf, "STATE.md"), "# State");
|
||||
|
||||
// Symlink .sf -> external directory
|
||||
symlinkSync(externalGsd, join(repo, ".sf"));
|
||||
symlinkSync(externalSf, join(repo, ".sf"));
|
||||
|
||||
// Add .gitignore so .sf/ is ignored
|
||||
writeFileSync(join(repo, ".gitignore"), ".sf\n");
|
||||
|
|
@ -1737,14 +1737,14 @@ describe("git-service", async () => {
|
|||
assert.ok(!staged.includes(".sf"), ".sf content not staged");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(externalGsd, { recursive: true, force: true });
|
||||
rmSync(externalSf, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("GitServiceImpl: symlinked .sf stages explicit untracked task files", () => {
|
||||
const repo = initTempRepo();
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "sf-external-"));
|
||||
mkdirSync(join(externalGsd, "activity"), { recursive: true });
|
||||
symlinkSync(externalGsd, join(repo, ".sf"));
|
||||
const externalSf = mkdtempSync(join(tmpdir(), "sf-external-"));
|
||||
mkdirSync(join(externalSf, "activity"), { recursive: true });
|
||||
symlinkSync(externalSf, join(repo, ".sf"));
|
||||
writeFileSync(join(repo, ".gitignore"), ".sf\n");
|
||||
createFile(repo, "cmd/installer/main.go", "package main\n");
|
||||
run("git add -A", repo);
|
||||
|
|
@ -1776,7 +1776,7 @@ describe("git-service", async () => {
|
|||
);
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(externalGsd, { recursive: true, force: true });
|
||||
rmSync(externalSf, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("GitServiceImpl: stageOnly ignores summary none placeholders", () => {
|
||||
|
|
@ -2029,12 +2029,12 @@ describe("git-service", async () => {
|
|||
const repo = initTempRepo();
|
||||
|
||||
// Create an external .sf directory and symlink it into the repo
|
||||
const externalGsd = mkdtempSync(join(tmpdir(), "sf-external-symlink-"));
|
||||
mkdirSync(join(externalGsd, "milestones", "M009"), { recursive: true });
|
||||
mkdirSync(join(externalGsd, "activity"), { recursive: true });
|
||||
mkdirSync(join(externalGsd, "runtime"), { recursive: true });
|
||||
const externalSf = mkdtempSync(join(tmpdir(), "sf-external-symlink-"));
|
||||
mkdirSync(join(externalSf, "milestones", "M009"), { recursive: true });
|
||||
mkdirSync(join(externalSf, "activity"), { recursive: true });
|
||||
mkdirSync(join(externalSf, "runtime"), { recursive: true });
|
||||
|
||||
symlinkSync(externalGsd, join(repo, ".sf"));
|
||||
symlinkSync(externalSf, join(repo, ".sf"));
|
||||
|
||||
// .gitignore blocks .sf (as ensureGitignore would do for symlink projects)
|
||||
writeFileSync(join(repo, ".gitignore"), ".sf\n");
|
||||
|
|
@ -2050,15 +2050,15 @@ describe("git-service", async () => {
|
|||
|
||||
// Simulate new milestone artifacts created during execution
|
||||
writeFileSync(
|
||||
join(externalGsd, "milestones", "M009", "M009-SUMMARY.md"),
|
||||
join(externalSf, "milestones", "M009", "M009-SUMMARY.md"),
|
||||
"# M009 Summary",
|
||||
);
|
||||
writeFileSync(
|
||||
join(externalGsd, "milestones", "M009", "S01-SUMMARY.md"),
|
||||
join(externalSf, "milestones", "M009", "S01-SUMMARY.md"),
|
||||
"# S01 Summary",
|
||||
);
|
||||
writeFileSync(
|
||||
join(externalGsd, "milestones", "M009", "T01-VERIFY.json"),
|
||||
join(externalSf, "milestones", "M009", "T01-VERIFY.json"),
|
||||
'{"passed":true}',
|
||||
);
|
||||
|
||||
|
|
@ -2086,7 +2086,7 @@ describe("git-service", async () => {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
} catch {}
|
||||
try {
|
||||
rmSync(externalGsd, { recursive: true, force: true });
|
||||
rmSync(externalSf, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* gitignore-staging-2570.test.ts — Regression tests for #2570.
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. isGsdGitignored() detects when .sf is covered by .gitignore
|
||||
* 1. isSfGitignored() detects when .sf is covered by .gitignore
|
||||
* 2. The rethink prompt uses {{commitInstruction}} instead of hardcoded git add .sf/
|
||||
* 3. rethink.ts passes the correct commitInstruction based on gitignore state
|
||||
*
|
||||
|
|
@ -22,8 +22,8 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
// Dynamic import — isGsdGitignored is the function under test (may not exist yet during TDD red phase)
|
||||
const { isGsdGitignored } = await import("../../gitignore.ts");
|
||||
// Dynamic import — isSfGitignored is the function under test (may not exist yet during TDD red phase)
|
||||
const { isSfGitignored } = await import("../../gitignore.ts");
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -55,19 +55,19 @@ function cleanup(dir: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── isGsdGitignored ─────────────────────────────────────────────────
|
||||
// ─── isSfGitignored ─────────────────────────────────────────────────
|
||||
|
||||
test("isGsdGitignored returns true when .sf is in .gitignore (#2570)", (t) => {
|
||||
test("isSfGitignored returns true when .sf is in .gitignore (#2570)", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
writeFileSync(join(dir, ".gitignore"), ".sf\n");
|
||||
assert.equal(isGsdGitignored(dir), true);
|
||||
assert.equal(isSfGitignored(dir), true);
|
||||
});
|
||||
|
||||
test("isGsdGitignored returns true when .sf/ (with slash) is in .gitignore", (t) => {
|
||||
test("isSfGitignored returns true when .sf/ (with slash) is in .gitignore", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
|
|
@ -76,27 +76,27 @@ test("isGsdGitignored returns true when .sf/ (with slash) is in .gitignore", (t)
|
|||
writeFileSync(join(dir, ".gitignore"), ".sf/\n");
|
||||
// Create .sf directory so git check-ignore can match the directory-only pattern
|
||||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
assert.equal(isGsdGitignored(dir), true);
|
||||
assert.equal(isSfGitignored(dir), true);
|
||||
});
|
||||
|
||||
test("isGsdGitignored returns false when .sf is NOT in .gitignore", (t) => {
|
||||
test("isSfGitignored returns false when .sf is NOT in .gitignore", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
|
||||
assert.equal(isGsdGitignored(dir), false);
|
||||
assert.equal(isSfGitignored(dir), false);
|
||||
});
|
||||
|
||||
test("isGsdGitignored returns false when no .gitignore exists", (t) => {
|
||||
test("isSfGitignored returns false when no .gitignore exists", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
// No .gitignore — default
|
||||
assert.equal(isGsdGitignored(dir), false);
|
||||
assert.equal(isSfGitignored(dir), false);
|
||||
});
|
||||
|
||||
// ─── rethink.md prompt template ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { ensureGitignore, hasGitTrackedGsdFiles } from "../../gitignore.ts";
|
||||
import { ensureGitignore, hasGitTrackedSfFiles } from "../../gitignore.ts";
|
||||
import { migrateToExternalState } from "../../migrate-external.ts";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
|
@ -55,18 +55,18 @@ function cleanup(dir: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── hasGitTrackedGsdFiles ───────────────────────────────────────────
|
||||
// ─── hasGitTrackedSfFiles ───────────────────────────────────────────
|
||||
|
||||
test("hasGitTrackedGsdFiles returns false when .sf/ does not exist", (t) => {
|
||||
test("hasGitTrackedSfFiles returns false when .sf/ does not exist", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
assert.equal(hasGitTrackedGsdFiles(dir), false);
|
||||
assert.equal(hasGitTrackedSfFiles(dir), false);
|
||||
});
|
||||
|
||||
test("hasGitTrackedGsdFiles returns true when .sf/ has tracked files", (t) => {
|
||||
test("hasGitTrackedSfFiles returns true when .sf/ has tracked files", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
|
|
@ -76,10 +76,10 @@ test("hasGitTrackedGsdFiles returns true when .sf/ has tracked files", (t) => {
|
|||
writeFileSync(join(dir, ".sf", "PROJECT.md"), "# Test Project\n");
|
||||
git(dir, "add", ".sf/PROJECT.md");
|
||||
git(dir, "commit", "-m", "add sf");
|
||||
assert.equal(hasGitTrackedGsdFiles(dir), true);
|
||||
assert.equal(hasGitTrackedSfFiles(dir), true);
|
||||
});
|
||||
|
||||
test("hasGitTrackedGsdFiles returns false when .sf/ exists but is untracked", (t) => {
|
||||
test("hasGitTrackedSfFiles returns false when .sf/ exists but is untracked", (t) => {
|
||||
const dir = makeTempRepo();
|
||||
t.after(() => {
|
||||
cleanup(dir);
|
||||
|
|
@ -88,7 +88,7 @@ test("hasGitTrackedGsdFiles returns false when .sf/ exists but is untracked", (t
|
|||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
writeFileSync(join(dir, ".sf", "STATE.md"), "state\n");
|
||||
// Not git-added — should return false
|
||||
assert.equal(hasGitTrackedGsdFiles(dir), false);
|
||||
assert.equal(hasGitTrackedSfFiles(dir), false);
|
||||
});
|
||||
|
||||
// ─── ensureGitignore — tracked .sf/ protection ─────────────────────
|
||||
|
|
@ -125,19 +125,19 @@ test("ensureGitignore does NOT add .sf when .sf/ has tracked files (#1364)", (_t
|
|||
}
|
||||
});
|
||||
|
||||
test("ensureGitignore adds .sf when .sf/ has NO tracked files", (_t) => {
|
||||
test("ensureGitignore excludes .sf when .sf/ has NO tracked files", (_t) => {
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
// Run ensureGitignore (no .sf/ at all)
|
||||
ensureGitignore(dir);
|
||||
|
||||
// Verify .sf IS in .gitignore
|
||||
const gitignore = readFileSync(join(dir, ".gitignore"), "utf-8");
|
||||
const lines = gitignore.split("\n").map((l) => l.trim());
|
||||
const exclude = readFileSync(join(dir, ".git", "info", "exclude"), "utf-8");
|
||||
const lines = exclude.split("\n").map((l) => l.trim());
|
||||
assert.ok(
|
||||
lines.includes(".sf"),
|
||||
`Expected .sf in .gitignore, but it's missing:\n${gitignore}`,
|
||||
`Expected .sf in .git/info/exclude, but it's missing:\n${exclude}`,
|
||||
);
|
||||
assert.doesNotThrow(() => git(dir, "check-ignore", "-q", ".sf"));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ test("ensureGitignore with tracked .sf/ does not cause git to see files as delet
|
|||
}
|
||||
});
|
||||
|
||||
test("hasGitTrackedGsdFiles returns true (fail-safe) when git is not available", (_t) => {
|
||||
test("hasGitTrackedSfFiles returns true (fail-safe) when git is not available", (_t) => {
|
||||
const dir = makeTempRepo();
|
||||
try {
|
||||
// Create and track .sf/ files
|
||||
|
|
@ -208,7 +208,7 @@ test("hasGitTrackedGsdFiles returns true (fail-safe) when git is not available",
|
|||
|
||||
// Should fail safe — assume tracked rather than silently returning false
|
||||
// (The index lock causes git ls-files to fail; rev-parse also fails → true)
|
||||
const result = hasGitTrackedGsdFiles(dir);
|
||||
const result = hasGitTrackedSfFiles(dir);
|
||||
assert.equal(
|
||||
result,
|
||||
true,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ function run(cmd: string, args: string[], cwd: string): string {
|
|||
describe("isInheritedRepo when git root is HOME (#2393)", () => {
|
||||
let fakeHome: string;
|
||||
let stateDir: string;
|
||||
let origGsdHome: string | undefined;
|
||||
let origGsdStateDir: string | undefined;
|
||||
let origSfHome: string | undefined;
|
||||
let origSfStateDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fake HOME that is itself a git repo (dotfile manager scenario).
|
||||
|
|
@ -56,18 +56,18 @@ describe("isInheritedRepo when git root is HOME (#2393)", () => {
|
|||
|
||||
// Save and override env. Point SF_HOME at fakeHome/.sf so the
|
||||
// function recognizes it as the global state directory.
|
||||
origGsdHome = process.env.SF_HOME;
|
||||
origGsdStateDir = process.env.SF_STATE_DIR;
|
||||
origSfHome = process.env.SF_HOME;
|
||||
origSfStateDir = process.env.SF_STATE_DIR;
|
||||
process.env.SF_HOME = join(fakeHome, ".sf");
|
||||
stateDir = mkdtempSync(join(tmpdir(), "sf-state-"));
|
||||
process.env.SF_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origGsdHome !== undefined) process.env.SF_HOME = origGsdHome;
|
||||
if (origSfHome !== undefined) process.env.SF_HOME = origSfHome;
|
||||
else delete process.env.SF_HOME;
|
||||
if (origGsdStateDir !== undefined)
|
||||
process.env.SF_STATE_DIR = origGsdStateDir;
|
||||
if (origSfStateDir !== undefined)
|
||||
process.env.SF_STATE_DIR = origSfStateDir;
|
||||
else delete process.env.SF_STATE_DIR;
|
||||
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
|
|
@ -147,12 +147,12 @@ describe("isInheritedRepo with stale .sf at parent git root", () => {
|
|||
const projectDir = join(parentRepo, "my-project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Without fix: isProjectGsd(join(root, ".sf")) returns true because
|
||||
// Without fix: isProjectSf(join(root, ".sf")) returns true because
|
||||
// the stale .sf is a real directory that isn't the global SF home,
|
||||
// causing isInheritedRepo to return false (false negative).
|
||||
//
|
||||
// The stale .sf at parent is still treated as a "project .sf" by
|
||||
// isProjectGsd(), so the git root check at line 128 returns false.
|
||||
// isProjectSf(), so the git root check at line 128 returns false.
|
||||
// This is the expected behavior for that check — the defense-in-depth
|
||||
// fix in auto-start.ts handles this case by checking for local .git.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -562,9 +562,9 @@ test("mergeAllCompleted — by-completion order respects startedAt", async () =>
|
|||
|
||||
/** Set up a worktree DB with a milestone marked complete */
|
||||
function setupWorktreeDb(basePath: string, mid: string): void {
|
||||
const wtGsdDir = join(basePath, ".sf", "worktrees", mid, ".sf");
|
||||
mkdirSync(wtGsdDir, { recursive: true });
|
||||
const dbPath = join(wtGsdDir, "sf.db");
|
||||
const wtSfDir = join(basePath, ".sf", "worktrees", mid, ".sf");
|
||||
mkdirSync(wtSfDir, { recursive: true });
|
||||
const dbPath = join(wtSfDir, "sf.db");
|
||||
openDatabase(dbPath);
|
||||
insertMilestone({ id: mid, title: `Milestone ${mid}`, status: "complete" });
|
||||
updateMilestoneStatus(mid, "complete", new Date().toISOString());
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
import { _clearGsdRootCache, sfRoot } from "../../paths.ts";
|
||||
import { _clearSfRootCache, sfRoot } from "../../paths.ts";
|
||||
|
||||
/** Create a tmp dir and resolve symlinks + 8.3 short names (macOS /var→/private/var, Windows RUNNER~1→runneradmin). */
|
||||
function tmp(): string {
|
||||
|
|
@ -35,7 +35,7 @@ describe("paths", () => {
|
|||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".sf"));
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const result = sfRoot(root);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
|
|
@ -54,7 +54,7 @@ describe("paths", () => {
|
|||
mkdirSync(join(root, ".sf"));
|
||||
const sub = join(root, "src", "deep");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const result = sfRoot(sub);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
|
|
@ -74,7 +74,7 @@ describe("paths", () => {
|
|||
mkdirSync(join(project, ".sf"), { recursive: true });
|
||||
const deep = join(project, "src", "deep");
|
||||
mkdirSync(deep, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const result = sfRoot(deep);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
|
|
@ -92,7 +92,7 @@ describe("paths", () => {
|
|||
initGit(root);
|
||||
const sub = join(root, "src");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const result = sfRoot(sub);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
|
|
@ -108,7 +108,7 @@ describe("paths", () => {
|
|||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".sf"));
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const first = sfRoot(root);
|
||||
const second = sfRoot(root);
|
||||
assert.deepStrictEqual(
|
||||
|
|
@ -129,7 +129,7 @@ describe("paths", () => {
|
|||
mkdirSync(join(outer, ".sf"));
|
||||
const inner = join(outer, "nested");
|
||||
mkdirSync(join(inner, ".sf"), { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
const result = sfRoot(inner);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ console.log(
|
|||
const dbDecisionsContent = formatDecisionsForPrompt(scopedDecisions);
|
||||
const dbRequirementsContent = formatRequirementsForPrompt(scopedRequirements);
|
||||
|
||||
// ── Full-markdown equivalents (what inlineGsdRootFile would return) ──
|
||||
// ── Full-markdown equivalents (what inlineSfRootFile would return) ──
|
||||
const fullDecisionsContent = readFileSync(
|
||||
join(base, ".sf", "DECISIONS.md"),
|
||||
"utf-8",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Tests:
|
||||
* - KNOWLEDGE is registered in SF_ROOT_FILES
|
||||
* - resolveSfRootFile resolves KNOWLEDGE paths correctly
|
||||
* - inlineGsdRootFile works with the KNOWLEDGE key
|
||||
* - inlineSfRootFile works with the KNOWLEDGE key
|
||||
* - before_agent_start hook includes/omits knowledge block appropriately
|
||||
* - loadKnowledgeBlock merges global and project knowledge correctly
|
||||
*/
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { inlineGsdRootFile, inlineKnowledgeBudgeted } from "../auto-prompts.ts";
|
||||
import { inlineSfRootFile, inlineKnowledgeBudgeted } from "../auto-prompts.ts";
|
||||
import { loadKnowledgeBlock } from "../bootstrap/system-context.ts";
|
||||
import { appendKnowledge } from "../files.ts";
|
||||
import { resolveSfRootFile, SF_ROOT_FILES } from "../paths.ts";
|
||||
|
|
@ -80,9 +80,9 @@ test("knowledge: resolveSfRootFile returns canonical path when file does not exi
|
|||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── inlineGsdRootFile works with knowledge.md ─────────────────────────────
|
||||
// ─── inlineSfRootFile works with knowledge.md ─────────────────────────────
|
||||
|
||||
test("knowledge: inlineGsdRootFile returns content when KNOWLEDGE.md exists", async () => {
|
||||
test("knowledge: inlineSfRootFile returns content when KNOWLEDGE.md exists", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "sf-knowledge-"));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
|
|
@ -91,7 +91,7 @@ test("knowledge: inlineGsdRootFile returns content when KNOWLEDGE.md exists", as
|
|||
"# Project Knowledge\n\n## Rules\n\nK001: Use real DB",
|
||||
);
|
||||
|
||||
const result = await inlineGsdRootFile(
|
||||
const result = await inlineSfRootFile(
|
||||
tmp,
|
||||
"knowledge.md",
|
||||
"Project Knowledge",
|
||||
|
|
@ -103,12 +103,12 @@ test("knowledge: inlineGsdRootFile returns content when KNOWLEDGE.md exists", as
|
|||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("knowledge: inlineGsdRootFile returns null when KNOWLEDGE.md does not exist", async () => {
|
||||
test("knowledge: inlineSfRootFile returns null when KNOWLEDGE.md does not exist", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "sf-knowledge-"));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
|
||||
const result = await inlineGsdRootFile(
|
||||
const result = await inlineSfRootFile(
|
||||
tmp,
|
||||
"knowledge.md",
|
||||
"Project Knowledge",
|
||||
|
|
@ -306,9 +306,9 @@ test("loadKnowledgeBlock: reports globalSizeKb above 4KB threshold", () => {
|
|||
// helper scopes by milestone-level keywords and caps the injected size.
|
||||
|
||||
test("inlineKnowledgeBudgeted: returns scoped H3 entries for single-H2 file", async () => {
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "gsd-knowledge-")));
|
||||
const gsdDir = join(tmp, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "sf-knowledge-")));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
|
||||
const content = `# Project Knowledge
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ Use /v1/resource style versioning.
|
|||
### Testing: node:test
|
||||
Prefer node:test over external frameworks.
|
||||
`;
|
||||
writeFileSync(join(gsdDir, "KNOWLEDGE.md"), content);
|
||||
writeFileSync(join(sfDir, "KNOWLEDGE.md"), content);
|
||||
|
||||
const result = await inlineKnowledgeBudgeted(tmp, ["database"]);
|
||||
assert.ok(result !== null, "should return content");
|
||||
|
|
@ -340,9 +340,9 @@ Prefer node:test over external frameworks.
|
|||
});
|
||||
|
||||
test("inlineKnowledgeBudgeted: caps payload below budget for large files", async () => {
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "gsd-knowledge-")));
|
||||
const gsdDir = join(tmp, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "sf-knowledge-")));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
|
||||
// Build a 200KB KNOWLEDGE with 500 H3 entries all matching 'shared'
|
||||
const entries = Array.from(
|
||||
|
|
@ -350,7 +350,7 @@ test("inlineKnowledgeBudgeted: caps payload below budget for large files", async
|
|||
(_, i) => `### Entry ${i}: shared topic\n${"filler text ".repeat(30)}\n`,
|
||||
).join("\n");
|
||||
const content = `# Project Knowledge\n\n## Patterns\n\n${entries}`;
|
||||
writeFileSync(join(gsdDir, "KNOWLEDGE.md"), content);
|
||||
writeFileSync(join(sfDir, "KNOWLEDGE.md"), content);
|
||||
|
||||
const BUDGET_CHARS = 30_000;
|
||||
const result = await inlineKnowledgeBudgeted(tmp, ["shared"], {
|
||||
|
|
@ -377,9 +377,9 @@ test("inlineKnowledgeBudgeted: caps payload below budget for large files", async
|
|||
});
|
||||
|
||||
test("inlineKnowledgeBudgeted: returns null when no KNOWLEDGE.md exists", async () => {
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "gsd-knowledge-")));
|
||||
const gsdDir = join(tmp, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "sf-knowledge-")));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
|
||||
const result = await inlineKnowledgeBudgeted(tmp, ["database"]);
|
||||
assert.strictEqual(result, null);
|
||||
|
|
@ -388,11 +388,11 @@ test("inlineKnowledgeBudgeted: returns null when no KNOWLEDGE.md exists", async
|
|||
});
|
||||
|
||||
test("inlineKnowledgeBudgeted: returns null when no entries match", async () => {
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "gsd-knowledge-")));
|
||||
const gsdDir = join(tmp, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "sf-knowledge-")));
|
||||
const sfDir = join(tmp, ".sf");
|
||||
mkdirSync(sfDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsdDir, "KNOWLEDGE.md"),
|
||||
join(sfDir, "KNOWLEDGE.md"),
|
||||
"# Project Knowledge\n\n## Patterns\n\n### Database\nuse it\n",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
repairStaleRenders,
|
||||
} from "../markdown-renderer.ts";
|
||||
import { parsePlan, parseRoadmap } from "../parsers-legacy.ts";
|
||||
import { _clearGsdRootCache, clearPathCache } from "../paths.ts";
|
||||
import { _clearSfRootCache, clearPathCache } from "../paths.ts";
|
||||
import {
|
||||
_getAdapter,
|
||||
closeDatabase,
|
||||
|
|
@ -52,7 +52,7 @@ function cleanupDir(dir: string): void {
|
|||
function clearAllCaches(): void {
|
||||
clearParseCache();
|
||||
clearPathCache();
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
invalidateStateCache();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ describe("migrate-external worktree guard (#2970)", () => {
|
|||
run(`git worktree add -b milestone/M001 ${worktreePath}`, base);
|
||||
|
||||
// Populate worktree with a .sf directory (simulating syncSfStateToWorktree)
|
||||
const worktreeGsd = join(worktreePath, ".sf");
|
||||
mkdirSync(worktreeGsd, { recursive: true });
|
||||
writeFileSync(join(worktreeGsd, "PREFERENCES.md"), "# prefs\n", "utf-8");
|
||||
const worktreeSf = join(worktreePath, ".sf");
|
||||
mkdirSync(worktreeSf, { recursive: true });
|
||||
writeFileSync(join(worktreeSf, "PREFERENCES.md"), "# prefs\n", "utf-8");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// GSD-2 — #4781: classifier behavior matrix. Pure-function tests, no I/O.
|
||||
// SF — #4781: classifier behavior matrix. Pure-function tests, no I/O.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import test, { afterEach } from "node:test";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveDispatch } from "../auto-dispatch.ts";
|
||||
import { buildParallelResearchSlicesPrompt } from "../auto-prompts.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -94,6 +95,17 @@ test("dispatch: parallel-research-slices requires 2+ slices", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("dispatch: parallel-research-slices respects subagent batch limit", () => {
|
||||
assert.ok(
|
||||
dispatchSrc.includes("MAX_PARALLEL_RESEARCH_SLICES"),
|
||||
"rule should guard the subagent max parallel task count",
|
||||
);
|
||||
assert.ok(
|
||||
dispatchSrc.includes("researchReadySlices.length > MAX_PARALLEL_RESEARCH_SLICES"),
|
||||
"rule should fall back when too many slices are ready for one subagent call",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: parallel-research-slices respects skip_research", () => {
|
||||
const ruleIdx = dispatchSrc.indexOf("parallel-research-slices");
|
||||
const ruleBlock = dispatchSrc.slice(ruleIdx, ruleIdx + 500);
|
||||
|
|
@ -122,6 +134,27 @@ test("prompt: builds per-slice subagent prompts", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("prompt: emits deterministic worker subagent payload", async () => {
|
||||
const base = makeTmpProject();
|
||||
const prompt = await buildParallelResearchSlicesPrompt(
|
||||
"M001",
|
||||
"Parallel Research Milestone",
|
||||
[
|
||||
{ id: "S01", title: "Alpha" },
|
||||
{ id: "S02", title: "Beta" },
|
||||
],
|
||||
base,
|
||||
"test-subagent-model",
|
||||
);
|
||||
|
||||
assert.match(prompt, /Required `subagent` Call Payload/);
|
||||
assert.match(prompt, /"agent": "worker"/);
|
||||
assert.match(prompt, /"cwd":/);
|
||||
assert.match(prompt, /"model": "test-subagent-model"/);
|
||||
assert.match(prompt, /IMPORTANT CHILD-AGENT OVERRIDE/);
|
||||
assert.doesNotMatch(prompt, /"agent": "g(?:sd)-executor"/);
|
||||
});
|
||||
|
||||
// ─── Template ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("template: parallel-research-slices.md has required variables", () => {
|
||||
|
|
@ -130,6 +163,10 @@ test("template: parallel-research-slices.md has required variables", () => {
|
|||
"template should use sliceCount",
|
||||
);
|
||||
assert.ok(templateSrc.includes("{{mid}}"), "template should use mid");
|
||||
assert.ok(
|
||||
templateSrc.includes("{{subagentCall}}"),
|
||||
"template should use subagentCall",
|
||||
);
|
||||
assert.ok(
|
||||
templateSrc.includes("{{subagentPrompts}}"),
|
||||
"template should use subagentPrompts",
|
||||
|
|
|
|||
|
|
@ -158,24 +158,24 @@ describe("parallel-worker-lock-contention (#2184)", () => {
|
|||
// ─── Bug 3: syncProjectRootToWorktree skips same-path symlinks ───────────
|
||||
test("Bug 3: syncProjectRootToWorktree skips when .sf resolves to same path (symlink)", () => {
|
||||
const base = mkdtempSync(join(tmpdir(), "sf-symlink-sync-"));
|
||||
const externalGsd = join(base, "external-sf");
|
||||
const externalSf = join(base, "external-sf");
|
||||
const projectRoot = join(base, "project");
|
||||
const worktreePath = join(base, "worktree");
|
||||
|
||||
mkdirSync(externalGsd, { recursive: true });
|
||||
mkdirSync(externalSf, { recursive: true });
|
||||
mkdirSync(projectRoot, { recursive: true });
|
||||
mkdirSync(worktreePath, { recursive: true });
|
||||
|
||||
// Create the external state directory with a milestone
|
||||
mkdirSync(join(externalGsd, "milestones", "M001"), { recursive: true });
|
||||
mkdirSync(join(externalSf, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(externalGsd, "milestones", "M001", "M001-ROADMAP.md"),
|
||||
join(externalSf, "milestones", "M001", "M001-ROADMAP.md"),
|
||||
"# Roadmap",
|
||||
);
|
||||
|
||||
// Symlink both project and worktree .sf to the same external directory
|
||||
symlinkSync(externalGsd, join(projectRoot, ".sf"));
|
||||
symlinkSync(externalGsd, join(worktreePath, ".sf"));
|
||||
symlinkSync(externalSf, join(projectRoot, ".sf"));
|
||||
symlinkSync(externalSf, join(worktreePath, ".sf"));
|
||||
|
||||
try {
|
||||
// This should NOT throw ERR_FS_CP_EINVAL — it should skip silently
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
type VerificationContext,
|
||||
} from "../auto-verification.ts";
|
||||
import { invalidateAllCaches } from "../cache.ts";
|
||||
import { _clearGsdRootCache } from "../paths.ts";
|
||||
import { _clearSfRootCache } from "../paths.ts";
|
||||
import {
|
||||
_getAdapter,
|
||||
closeDatabase,
|
||||
|
|
@ -93,7 +93,7 @@ function setupTestEnvironment(): void {
|
|||
mkdirSync(milestonesDir, { recursive: true });
|
||||
|
||||
process.chdir(tempDir);
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
|
||||
dbPath = join(sfDir, "sf.db");
|
||||
openDatabase(dbPath);
|
||||
|
|
@ -129,7 +129,7 @@ ${yamlLines.join("\n")}
|
|||
`;
|
||||
writeFileSync(join(tempDir, ".sf", "PREFERENCES.md"), prefsContent);
|
||||
invalidateAllCaches();
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
postUnitPostVerification,
|
||||
} from "../auto-post-unit.ts";
|
||||
import { invalidateAllCaches } from "../cache.ts";
|
||||
import { _clearGsdRootCache } from "../paths.ts";
|
||||
import { _clearSfRootCache } from "../paths.ts";
|
||||
import {
|
||||
closeDatabase,
|
||||
insertMilestone,
|
||||
|
|
@ -107,7 +107,7 @@ function setupTestEnvironment(): void {
|
|||
mkdirSync(milestonesDir, { recursive: true });
|
||||
|
||||
process.chdir(tempDir);
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
|
||||
dbPath = join(sfDir, "sf.db");
|
||||
openDatabase(dbPath);
|
||||
|
|
@ -143,7 +143,7 @@ ${yamlLines.join("\n")}
|
|||
`;
|
||||
writeFileSync(join(tempDir, ".sf", "PREFERENCES.md"), prefsContent);
|
||||
invalidateAllCaches();
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
postUnitPostVerification,
|
||||
} from "../auto-post-unit.ts";
|
||||
import { invalidateAllCaches } from "../cache.ts";
|
||||
import { _clearGsdRootCache } from "../paths.ts";
|
||||
import { _clearSfRootCache } from "../paths.ts";
|
||||
import {
|
||||
_getAdapter,
|
||||
closeDatabase,
|
||||
|
|
@ -135,7 +135,7 @@ function setupTestEnvironment(): void {
|
|||
process.chdir(tempDir);
|
||||
|
||||
// Clear sfRoot cache so it finds the new .sf directory
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
|
||||
// Initialize DB
|
||||
dbPath = join(sfDir, "sf.db");
|
||||
|
|
@ -183,7 +183,7 @@ ${yamlLines.join("\n")}
|
|||
writeFileSync(join(tempDir, ".sf", "PREFERENCES.md"), prefsContent);
|
||||
// Invalidate caches so the new preferences file is found
|
||||
invalidateAllCaches();
|
||||
_clearGsdRootCache();
|
||||
_clearSfRootCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -72,15 +72,15 @@ test("syncSfStateToWorktree copies canonical PREFERENCES.md", async () => {
|
|||
// Functional test: create a mock source and destination, call the sync
|
||||
const srcBase = mkdtempSync(join(tmpdir(), "sf-wt-prefs-src-"));
|
||||
const dstBase = mkdtempSync(join(tmpdir(), "sf-wt-prefs-dst-"));
|
||||
const srcGsd = join(srcBase, ".sf");
|
||||
const dstGsd = join(dstBase, ".sf");
|
||||
mkdirSync(srcGsd, { recursive: true });
|
||||
mkdirSync(dstGsd, { recursive: true });
|
||||
const srcSf = join(srcBase, ".sf");
|
||||
const dstSf = join(dstBase, ".sf");
|
||||
mkdirSync(srcSf, { recursive: true });
|
||||
mkdirSync(dstSf, { recursive: true });
|
||||
|
||||
try {
|
||||
// Write a canonical PREFERENCES.md in source
|
||||
writeFileSync(
|
||||
join(srcGsd, "PREFERENCES.md"),
|
||||
join(srcSf, "PREFERENCES.md"),
|
||||
"---\nversion: 1\n---\n\npost_unit_hooks:\n - name: notify\n command: echo done\n",
|
||||
);
|
||||
|
||||
|
|
@ -90,11 +90,11 @@ test("syncSfStateToWorktree copies canonical PREFERENCES.md", async () => {
|
|||
|
||||
// Verify PREFERENCES.md was copied
|
||||
assert.ok(
|
||||
existsSync(join(dstGsd, "PREFERENCES.md")),
|
||||
existsSync(join(dstSf, "PREFERENCES.md")),
|
||||
"PREFERENCES.md should be copied to worktree",
|
||||
);
|
||||
|
||||
const content = readFileSync(join(dstGsd, "PREFERENCES.md"), "utf-8");
|
||||
const content = readFileSync(join(dstSf, "PREFERENCES.md"), "utf-8");
|
||||
assert.ok(
|
||||
content.includes("post_unit_hooks"),
|
||||
"copied PREFERENCES.md should contain the hooks config",
|
||||
|
|
@ -108,21 +108,21 @@ test("syncSfStateToWorktree copies canonical PREFERENCES.md", async () => {
|
|||
test("syncSfStateToWorktree falls back to legacy lowercase preferences.md", async () => {
|
||||
const srcBase = mkdtempSync(join(tmpdir(), "sf-wt-prefs-legacy-src-"));
|
||||
const dstBase = mkdtempSync(join(tmpdir(), "sf-wt-prefs-legacy-dst-"));
|
||||
const srcGsd = join(srcBase, ".sf");
|
||||
const dstGsd = join(dstBase, ".sf");
|
||||
mkdirSync(srcGsd, { recursive: true });
|
||||
mkdirSync(dstGsd, { recursive: true });
|
||||
const srcSf = join(srcBase, ".sf");
|
||||
const dstSf = join(dstBase, ".sf");
|
||||
mkdirSync(srcSf, { recursive: true });
|
||||
mkdirSync(dstSf, { recursive: true });
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
join(srcGsd, "preferences.md"),
|
||||
join(srcSf, "preferences.md"),
|
||||
"---\nversion: 1\n---\n\ngit:\n auto_push: true\n",
|
||||
);
|
||||
|
||||
const { syncSfStateToWorktree } = await import("../auto-worktree.ts");
|
||||
const result = syncSfStateToWorktree(srcBase, dstBase);
|
||||
|
||||
const copiedEntries = readdirSync(dstGsd).filter(
|
||||
const copiedEntries = readdirSync(dstSf).filter(
|
||||
(name) => name === "PREFERENCES.md" || name === "preferences.md",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -673,15 +673,15 @@ test("experimental.rtk parses correctly from preferences markdown", () => {
|
|||
|
||||
test("loadEffectiveSFPreferences preserves experimental prefs across global+project merge", () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalGsdHome = process.env.SF_HOME;
|
||||
const originalSfHome = process.env.SF_HOME;
|
||||
const tempProject = mkdtempSync(join(tmpdir(), "sf-prefs-project-"));
|
||||
const tempGsdHome = mkdtempSync(join(tmpdir(), "sf-prefs-home-"));
|
||||
const tempSfHome = mkdtempSync(join(tmpdir(), "sf-prefs-home-"));
|
||||
|
||||
try {
|
||||
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempGsdHome, "preferences.md"),
|
||||
join(tempSfHome, "preferences.md"),
|
||||
["---", "version: 1", "experimental:", " rtk: true", "---"].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
|
@ -692,7 +692,7 @@ test("loadEffectiveSFPreferences preserves experimental prefs across global+proj
|
|||
"utf-8",
|
||||
);
|
||||
|
||||
process.env.SF_HOME = tempGsdHome;
|
||||
process.env.SF_HOME = tempSfHome;
|
||||
process.chdir(tempProject);
|
||||
|
||||
const loaded = loadEffectiveSFPreferences();
|
||||
|
|
@ -701,10 +701,10 @@ test("loadEffectiveSFPreferences preserves experimental prefs across global+proj
|
|||
assert.equal(loaded!.preferences.git?.isolation, "none");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalGsdHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalGsdHome;
|
||||
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
||||
else process.env.SF_HOME = originalSfHome;
|
||||
rmSync(tempProject, { recursive: true, force: true });
|
||||
rmSync(tempGsdHome, { recursive: true, force: true });
|
||||
rmSync(tempSfHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* silent data loss. When a repo has a remote URL, the identity hash
|
||||
* should be based solely on the remote — making moves transparent.
|
||||
*
|
||||
* For local-only repos (no remote), ensureGsdSymlink should detect
|
||||
* For local-only repos (no remote), ensureSfSymlink should detect
|
||||
* orphaned state directories with a matching .sf-id marker and
|
||||
* recover them automatically.
|
||||
*/
|
||||
|
|
@ -28,7 +28,7 @@ import { join } from "node:path";
|
|||
import { after, before, describe, test } from "node:test";
|
||||
|
||||
import {
|
||||
ensureGsdSymlink,
|
||||
ensureSfSymlink,
|
||||
externalProjectsRoot,
|
||||
readRepoMeta,
|
||||
repoIdentity,
|
||||
|
|
@ -105,14 +105,14 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
rmSync(repoB, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("ensureGsdSymlink reuses the same external dir after repo move (remote repo)", () => {
|
||||
test("ensureSfSymlink reuses the same external dir after repo move (remote repo)", () => {
|
||||
const repoA = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "sf-reloc-reuse-a-")),
|
||||
);
|
||||
initRepo(repoA, "https://github.com/example/reloc-reuse.git");
|
||||
|
||||
// Initialize SF state with some planning data
|
||||
const externalA = ensureGsdSymlink(repoA);
|
||||
const externalA = ensureSfSymlink(repoA);
|
||||
const milestonesPath = join(externalA, "milestones");
|
||||
mkdirSync(milestonesPath, { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -128,8 +128,8 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
);
|
||||
renameSync(repoA, repoB);
|
||||
|
||||
// ensureGsdSymlink at the new location should find the same external dir
|
||||
const externalB = ensureGsdSymlink(repoB);
|
||||
// ensureSfSymlink at the new location should find the same external dir
|
||||
const externalB = ensureSfSymlink(repoB);
|
||||
|
||||
assert.strictEqual(
|
||||
normalizePath(externalB),
|
||||
|
|
@ -159,7 +159,7 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
const repoA = realpathSync(mkdtempSync(join(tmpdir(), "sf-reloc-meta-a-")));
|
||||
initRepo(repoA, "https://github.com/example/reloc-meta.git");
|
||||
|
||||
const externalA = ensureGsdSymlink(repoA);
|
||||
const externalA = ensureSfSymlink(repoA);
|
||||
const metaBefore = readRepoMeta(externalA);
|
||||
assert.ok(metaBefore !== null, "metadata should exist before move");
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
);
|
||||
renameSync(repoA, repoB);
|
||||
|
||||
const externalB = ensureGsdSymlink(repoB);
|
||||
const externalB = ensureSfSymlink(repoB);
|
||||
const metaAfter = readRepoMeta(externalB);
|
||||
assert.ok(metaAfter !== null, "metadata should exist after move");
|
||||
assert.strictEqual(
|
||||
|
|
@ -189,16 +189,16 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
|
||||
// ── Local-only repos: .sf-id marker provides recovery ────────────────
|
||||
|
||||
test("ensureGsdSymlink writes a .sf-id marker in the project root", () => {
|
||||
test("ensureSfSymlink writes a .sf-id marker in the project root", () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), "sf-reloc-marker-")));
|
||||
initRepo(repo);
|
||||
|
||||
ensureGsdSymlink(repo);
|
||||
ensureSfSymlink(repo);
|
||||
|
||||
const markerPath = join(repo, ".sf-id");
|
||||
assert.ok(
|
||||
existsSync(markerPath),
|
||||
".sf-id marker must be written by ensureGsdSymlink",
|
||||
".sf-id marker must be written by ensureSfSymlink",
|
||||
);
|
||||
|
||||
const markerId = readFileSync(markerPath, "utf-8").trim();
|
||||
|
|
@ -220,7 +220,7 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
// No remote — identity includes gitRoot
|
||||
|
||||
// Initialize SF state
|
||||
const externalA = ensureGsdSymlink(repoA);
|
||||
const externalA = ensureSfSymlink(repoA);
|
||||
mkdirSync(join(externalA, "milestones"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(externalA, "milestones", "M001.md"),
|
||||
|
|
@ -245,8 +245,8 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
"local-only repo identity changes with move (expected)",
|
||||
);
|
||||
|
||||
// But ensureGsdSymlink should detect .sf-id marker and recover
|
||||
const externalB = ensureGsdSymlink(repoB);
|
||||
// But ensureSfSymlink should detect .sf-id marker and recover
|
||||
const externalB = ensureSfSymlink(repoB);
|
||||
assert.ok(
|
||||
existsSync(join(externalB, "milestones", "M001.md")),
|
||||
"local-only repo must recover state via .sf-id marker after move",
|
||||
|
|
@ -280,7 +280,7 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
);
|
||||
initRepo(repoA, "https://github.com/example/no-orphan.git");
|
||||
|
||||
ensureGsdSymlink(repoA);
|
||||
ensureSfSymlink(repoA);
|
||||
|
||||
// Count project dirs before move
|
||||
const projectsDir = externalProjectsRoot();
|
||||
|
|
@ -295,7 +295,7 @@ describe("project-relocation-recovery (#2750)", () => {
|
|||
);
|
||||
renameSync(repoA, repoB);
|
||||
|
||||
ensureGsdSymlink(repoB);
|
||||
ensureSfSymlink(repoB);
|
||||
|
||||
const countAfter = readdirSync(projectsDir).length;
|
||||
assert.strictEqual(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue