From b29c12d5e58f0e1b0fca0d9d9fff77b554dd644c Mon Sep 17 00:00:00 2001 From: ace-pm Date: Wed, 15 Apr 2026 14:58:21 +0200 Subject: [PATCH] refactor(native): rename gsd_parser.rs to forge_parser.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final rebrand: rename remaining Rust source file to complete the gsd → forge transition. All parser references already use forge_parser after earlier commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 8 +- .gsd/CODEBASE.md | 482 +++ .gsd/audit/events.jsonl | 2 + .gsd/notifications.jsonl | 10 + CHANGELOG.md | 1206 +++--- CONTRIBUTING.md | 4 +- README.md | 210 +- docker/README.md | 28 +- docs/README.md | 6 +- ...DR-001-branchless-worktree-architecture.md | 94 +- docs/dev/ADR-003-pipeline-simplification.md | 8 +- .../ADR-004-capability-aware-model-routing.md | 2 +- ...-005-multi-model-provider-tool-strategy.md | 4 +- docs/dev/ADR-007-model-catalog-split.md | 2 +- docs/dev/ADR-008-IMPLEMENTATION-PLAN.md | 24 +- ...-gsd-tools-over-mcp-for-provider-parity.md | 4 +- docs/dev/ADR-009-IMPLEMENTATION-PLAN.md | 74 +- .../ADR-009-orchestration-kernel-refactor.md | 2 +- .../dev/ADR-010-pi-clean-seam-architecture.md | 90 +- docs/dev/FILE-SYSTEM-MAP.md | 298 +- .../PRD-branchless-worktree-architecture.md | 92 +- docs/dev/PRD-pi-clean-seam-refactor.md | 34 +- docs/dev/agent-knowledge-index.md | 198 +- docs/dev/architecture.md | 18 +- docs/dev/ci-cd-pipeline.md | 4 +- .../07-the-system-prompt-anatomy.md | 10 +- docs/dev/extending-pi/03-getting-started.md | 4 +- .../04-extension-locations-discovery.md | 8 +- .../05-extension-structure-styles.md | 6 +- .../25-slash-command-subcommand-patterns.md | 8 +- .../pi-context-optimization-opportunities.md | 4 +- .../698-browser-tools-feature-additions.md | 6 +- .../plans/2026-03-17-cicd-pipeline.md | 36 +- .../specs/2026-03-17-cicd-pipeline-design.md | 6 +- .../07-sessions-memory-that-branches.md | 2 +- .../what-is-pi/09-the-customization-stack.md | 14 +- ...providers-models-multi-model-by-default.md | 4 +- .../13-context-files-project-instructions.md | 10 +- .../19-building-branded-apps-on-top-of-pi.md | 62 +- docs/user-docs/auto-mode.md | 36 +- docs/user-docs/captures-triage.md | 16 +- docs/user-docs/commands.md | 240 +- docs/user-docs/configuration.md | 82 +- docs/user-docs/cost-management.md | 8 +- docs/user-docs/custom-models.md | 6 +- docs/user-docs/dynamic-model-routing.md | 2 +- docs/user-docs/getting-started.md | 80 +- docs/user-docs/git-strategy.md | 14 +- docs/user-docs/migration.md | 10 +- docs/user-docs/node-lts-macos.md | 2 +- docs/user-docs/parallel-orchestration.md | 82 +- docs/user-docs/providers.md | 52 +- docs/user-docs/remote-questions.md | 20 +- docs/user-docs/skills.md | 20 +- docs/user-docs/token-optimization.md | 14 +- docs/user-docs/troubleshooting.md | 80 +- docs/user-docs/visualizer.md | 6 +- docs/user-docs/web-interface.md | 4 +- docs/user-docs/working-in-teams.md | 42 +- docs/zh-CN/README.md | 6 +- docs/zh-CN/user-docs/auto-mode.md | 36 +- docs/zh-CN/user-docs/captures-triage.md | 16 +- docs/zh-CN/user-docs/commands.md | 240 +- docs/zh-CN/user-docs/configuration.md | 84 +- docs/zh-CN/user-docs/cost-management.md | 8 +- docs/zh-CN/user-docs/custom-models.md | 6 +- docs/zh-CN/user-docs/dynamic-model-routing.md | 2 +- docs/zh-CN/user-docs/getting-started.md | 80 +- docs/zh-CN/user-docs/git-strategy.md | 14 +- docs/zh-CN/user-docs/migration.md | 10 +- docs/zh-CN/user-docs/node-lts-macos.md | 2 +- .../zh-CN/user-docs/parallel-orchestration.md | 82 +- docs/zh-CN/user-docs/providers.md | 52 +- docs/zh-CN/user-docs/remote-questions.md | 20 +- docs/zh-CN/user-docs/skills.md | 20 +- docs/zh-CN/user-docs/token-optimization.md | 14 +- docs/zh-CN/user-docs/troubleshooting.md | 80 +- docs/zh-CN/user-docs/visualizer.md | 6 +- docs/zh-CN/user-docs/web-interface.md | 4 +- docs/zh-CN/user-docs/working-in-teams.md | 42 +- gitbook/README.md | 10 +- gitbook/configuration/custom-models.md | 6 +- gitbook/configuration/git-settings.md | 12 +- gitbook/configuration/mcp-servers.md | 6 +- gitbook/configuration/preferences.md | 12 +- gitbook/configuration/providers.md | 24 +- gitbook/core-concepts/auto-mode.md | 34 +- gitbook/core-concepts/project-structure.md | 12 +- gitbook/core-concepts/step-mode.md | 16 +- gitbook/features/captures.md | 8 +- gitbook/features/cost-management.md | 8 +- gitbook/features/dynamic-model-routing.md | 10 +- gitbook/features/github-sync.md | 8 +- gitbook/features/headless.md | 32 +- gitbook/features/parallel.md | 34 +- gitbook/features/remote-questions.md | 18 +- gitbook/features/skills.md | 10 +- gitbook/features/teams.md | 38 +- gitbook/features/token-optimization.md | 6 +- gitbook/features/visualizer.md | 8 +- gitbook/features/web-interface.md | 4 +- gitbook/features/workflow-templates.md | 20 +- gitbook/getting-started/first-project.md | 34 +- gitbook/getting-started/installation.md | 22 +- gitbook/reference/cli-flags.md | 54 +- gitbook/reference/commands.md | 132 +- gitbook/reference/environment-variables.md | 6 +- gitbook/reference/keyboard-shortcuts.md | 10 +- gitbook/reference/migration.md | 10 +- gitbook/reference/troubleshooting.md | 46 +- gsd-orchestrator/SKILL.md | 32 +- .../references/answer-injection.md | 8 +- gsd-orchestrator/references/commands.md | 64 +- gsd-orchestrator/references/json-result.md | 20 +- gsd-orchestrator/workflows/build-from-spec.md | 18 +- .../workflows/monitor-and-poll.md | 44 +- gsd-orchestrator/workflows/step-by-step.md | 42 +- .../src/{gsd_parser.rs => forge_parser.rs} | 0 packages/mcp-server/README.md | 36 +- .../mcp-server/src/workflow-tools.test.ts | 2 +- packages/rpc-client/README.md | 8 +- scripts/parallel-monitor.mjs | 3 + scripts/postinstall.js | 12 +- scripts/recover-gsd-1364.ps1 | 6 +- scripts/rtk-benchmark.mjs | 2 +- scripts/verify-s03.sh | 1 + src/cli.ts | 4 +- src/resources/SF-WORKFLOW.md | 42 +- src/resources/agents/worker.md | 2 +- .../BROWSER-TOOLS-V2-PROPOSAL.md | 2 +- src/resources/extensions/gsd/activity-log.ts | 184 - src/resources/extensions/gsd/atomic-write.ts | 185 - .../extensions/gsd/auto-artifact-paths.ts | 135 - src/resources/extensions/gsd/auto-budget.ts | 32 - .../extensions/gsd/auto-dashboard.ts | 975 ----- .../extensions/gsd/auto-direct-dispatch.ts | 276 -- src/resources/extensions/gsd/auto-dispatch.ts | 908 ----- src/resources/extensions/gsd/auto-loop.ts | 16 - .../extensions/gsd/auto-model-selection.ts | 561 --- .../extensions/gsd/auto-post-unit.ts | 1296 ------- src/resources/extensions/gsd/auto-prompts.ts | 2253 ----------- src/resources/extensions/gsd/auto-recovery.ts | 631 --- src/resources/extensions/gsd/auto-start.ts | 962 ----- .../extensions/gsd/auto-supervisor.ts | 79 - .../extensions/gsd/auto-timeout-recovery.ts | 279 -- src/resources/extensions/gsd/auto-timers.ts | 327 -- .../extensions/gsd/auto-tool-tracking.ts | 137 - .../extensions/gsd/auto-unit-closeout.ts | 76 - src/resources/extensions/gsd/auto-utils.ts | 25 - .../extensions/gsd/auto-verification.ts | 650 ---- src/resources/extensions/gsd/auto-worktree.ts | 2067 ---------- src/resources/extensions/gsd/auto.ts | 1789 --------- .../extensions/gsd/auto/detect-stuck.ts | 95 - .../extensions/gsd/auto/finalize-timeout.ts | 49 - .../extensions/gsd/auto/infra-errors.ts | 86 - .../extensions/gsd/auto/loop-deps.ts | 281 -- src/resources/extensions/gsd/auto/loop.ts | 624 --- src/resources/extensions/gsd/auto/phases.ts | 2006 ---------- src/resources/extensions/gsd/auto/resolve.ts | 106 - src/resources/extensions/gsd/auto/run-unit.ts | 158 - src/resources/extensions/gsd/auto/session.ts | 286 -- src/resources/extensions/gsd/auto/types.ts | 122 - .../gsd/bootstrap/agent-end-recovery.ts | 266 -- .../extensions/gsd/bootstrap/crash-log.ts | 32 - .../extensions/gsd/bootstrap/db-tools.ts | 1066 ------ .../extensions/gsd/bootstrap/dynamic-tools.ts | 193 - .../extensions/gsd/bootstrap/journal-tools.ts | 63 - .../gsd/bootstrap/notify-interceptor.ts | 34 - .../gsd/bootstrap/provider-error-resume.ts | 59 - .../extensions/gsd/bootstrap/query-tools.ts | 34 - .../gsd/bootstrap/register-extension.ts | 96 - .../gsd/bootstrap/register-hooks.ts | 481 --- .../gsd/bootstrap/register-shortcuts.ts | 98 - .../bootstrap/sanitize-complete-milestone.ts | 57 - .../gsd/bootstrap/system-context.ts | 535 --- .../gsd/bootstrap/tool-call-loop-guard.ts | 103 - .../extensions/gsd/bootstrap/write-gate.ts | 466 --- .../extensions/gsd/branch-patterns.ts | 16 - src/resources/extensions/gsd/cache.ts | 29 - src/resources/extensions/gsd/captures.ts | 571 --- src/resources/extensions/gsd/changelog.ts | 213 -- src/resources/extensions/gsd/claude-import.ts | 705 ---- .../extensions/gsd/codebase-generator.ts | 625 --- .../extensions/gsd/collision-diagnostics.ts | 332 -- .../extensions/gsd/commands-add-tests.ts | 137 - .../extensions/gsd/commands-backlog.ts | 182 - .../extensions/gsd/commands-bootstrap.ts | 263 -- src/resources/extensions/gsd/commands-cmux.ts | 174 - .../extensions/gsd/commands-codebase.ts | 197 - .../extensions/gsd/commands-config.ts | 108 - src/resources/extensions/gsd/commands-do.ts | 109 - .../extensions/gsd/commands-extensions.ts | 330 -- .../gsd/commands-extract-learnings.ts | 304 -- .../extensions/gsd/commands-handlers.ts | 454 --- .../extensions/gsd/commands-inspect.ts | 99 - src/resources/extensions/gsd/commands-logs.ts | 536 --- .../extensions/gsd/commands-maintenance.ts | 544 --- .../extensions/gsd/commands-mcp-status.ts | 293 -- .../extensions/gsd/commands-pr-branch.ts | 234 -- .../extensions/gsd/commands-prefs-wizard.ts | 864 ----- src/resources/extensions/gsd/commands-rate.ts | 55 - .../extensions/gsd/commands-session-report.ts | 101 - src/resources/extensions/gsd/commands-ship.ts | 219 -- .../gsd/commands-workflow-templates.ts | 543 --- src/resources/extensions/gsd/commands.ts | 17 - .../extensions/gsd/commands/catalog.ts | 403 -- .../extensions/gsd/commands/context.ts | 125 - .../extensions/gsd/commands/dispatcher.ts | 43 - .../extensions/gsd/commands/handlers/auto.ts | 158 - .../extensions/gsd/commands/handlers/core.ts | 482 --- .../handlers/notifications-handler.ts | 150 - .../extensions/gsd/commands/handlers/ops.ts | 245 -- .../gsd/commands/handlers/parallel.ts | 135 - .../gsd/commands/handlers/workflow.ts | 340 -- .../extensions/gsd/commands/index.ts | 20 - .../extensions/gsd/complexity-classifier.ts | 329 -- .../extensions/gsd/config-overlay.ts | 331 -- src/resources/extensions/gsd/constants.ts | 65 - .../extensions/gsd/context-budget.ts | 256 -- .../extensions/gsd/context-injector.ts | 100 - .../extensions/gsd/context-masker.ts | 74 - src/resources/extensions/gsd/context-store.ts | 361 -- .../extensions/gsd/crash-recovery.ts | 179 - .../extensions/gsd/custom-execution-policy.ts | 74 - .../extensions/gsd/custom-verification.ts | 183 - .../extensions/gsd/custom-workflow-engine.ts | 220 -- .../extensions/gsd/dashboard-overlay.ts | 666 ---- src/resources/extensions/gsd/db-writer.ts | 729 ---- src/resources/extensions/gsd/debug-logger.ts | 178 - src/resources/extensions/gsd/definition-io.ts | 18 - .../extensions/gsd/definition-loader.ts | 462 --- src/resources/extensions/gsd/detection.ts | 1154 ------ .../extensions/gsd/dev-execution-policy.ts | 51 - .../extensions/gsd/dev-workflow-engine.ts | 110 - src/resources/extensions/gsd/diff-context.ts | 214 -- .../extensions/gsd/dispatch-guard.ts | 143 - .../gsd/docs/claude-marketplace-import.md | 214 -- .../gsd/docs/preferences-reference.md | 694 ---- src/resources/extensions/gsd/doctor-checks.ts | 5 - .../extensions/gsd/doctor-engine-checks.ts | 196 - .../extensions/gsd/doctor-environment.ts | 642 ---- src/resources/extensions/gsd/doctor-format.ts | 99 - .../extensions/gsd/doctor-git-checks.ts | 489 --- .../extensions/gsd/doctor-global-checks.ts | 84 - .../extensions/gsd/doctor-proactive.ts | 465 --- .../extensions/gsd/doctor-providers.ts | 439 --- .../extensions/gsd/doctor-runtime-checks.ts | 630 --- src/resources/extensions/gsd/doctor-types.ts | 126 - src/resources/extensions/gsd/doctor.ts | 813 ---- .../extensions/gsd/engine-resolver.ts | 57 - src/resources/extensions/gsd/engine-types.ts | 71 - src/resources/extensions/gsd/env-utils.ts | 31 - .../extensions/gsd/error-classifier.ts | 144 - src/resources/extensions/gsd/error-utils.ts | 6 - src/resources/extensions/gsd/errors.ts | 29 - .../extensions/gsd/execution-policy.ts | 43 - src/resources/extensions/gsd/exit-command.ts | 30 - src/resources/extensions/gsd/export-html.ts | 1408 ------- src/resources/extensions/gsd/export.ts | 310 -- .../extensions/gsd/extension-manifest.json | 33 - src/resources/extensions/gsd/file-lock.ts | 59 - src/resources/extensions/gsd/files.ts | 1009 ----- src/resources/extensions/gsd/forensics.ts | 1210 ------ src/resources/extensions/gsd/gate-registry.ts | 251 -- src/resources/extensions/gsd/git-constants.ts | 12 - src/resources/extensions/gsd/git-self-heal.ts | 127 - src/resources/extensions/gsd/git-service.ts | 919 ----- src/resources/extensions/gsd/gitignore.ts | 322 -- src/resources/extensions/gsd/graph-context.ts | 212 -- src/resources/extensions/gsd/graph.ts | 312 -- src/resources/extensions/gsd/gsd-db.ts | 3378 ----------------- .../extensions/gsd/guided-flow-queue.ts | 439 --- src/resources/extensions/gsd/guided-flow.ts | 1940 ---------- .../extensions/gsd/health-widget-core.ts | 111 - src/resources/extensions/gsd/health-widget.ts | 143 - src/resources/extensions/gsd/history.ts | 144 - src/resources/extensions/gsd/index.ts | 37 - src/resources/extensions/gsd/init-wizard.ts | 638 ---- .../extensions/gsd/interrupted-session.ts | 225 -- src/resources/extensions/gsd/journal.ts | 169 - .../extensions/gsd/json-persistence.ts | 78 - src/resources/extensions/gsd/jsonl-utils.ts | 21 - src/resources/extensions/gsd/key-manager.ts | 989 ----- .../gsd/learning/bayesian-blender.mjs | 216 -- .../gsd/learning/bayesian-blender.test.mjs | 268 -- .../gsd/learning/data/model-benchmarks.json | 793 ---- .../learning/data/primary-provider-chain.json | 5 - .../gsd/learning/data/unit-weights.json | 125 - .../gsd/learning/fallback-chain-writer.mjs | 469 --- .../learning/fallback-chain-writer.test.mjs | 402 -- .../extensions/gsd/learning/hook-handler.mjs | 278 -- .../gsd/learning/hook-handler.test.mjs | 346 -- .../extensions/gsd/learning/index.mjs | 320 -- .../gsd/learning/integration.test.mjs | 367 -- .../gsd/learning/loadCapabilityOverrides.mjs | 436 --- .../learning/loadCapabilityOverrides.test.mjs | 217 -- .../gsd/learning/outcome-aggregator.mjs | 305 -- .../gsd/learning/outcome-recorder.mjs | 299 -- .../gsd/learning/outcome-recorder.test.mjs | 494 --- .../gsd/learning/outcome-schema.sql | 30 - .../extensions/gsd/learning/runtime.ts | 98 - .../extensions/gsd/markdown-renderer.ts | 1126 ------ .../extensions/gsd/marketplace-discovery.ts | 508 --- .../extensions/gsd/mcp-project-config.ts | 128 - src/resources/extensions/gsd/md-importer.ts | 748 ---- .../extensions/gsd/memory-extractor.ts | 360 -- src/resources/extensions/gsd/memory-store.ts | 421 -- src/resources/extensions/gsd/metrics.ts | 695 ---- .../extensions/gsd/migrate-external.ts | 210 - .../extensions/gsd/migrate/command.ts | 219 -- src/resources/extensions/gsd/migrate/index.ts | 42 - .../extensions/gsd/migrate/parser.ts | 323 -- .../extensions/gsd/migrate/parsers.ts | 539 --- .../extensions/gsd/migrate/preview.ts | 48 - .../extensions/gsd/migrate/transformer.ts | 346 -- src/resources/extensions/gsd/migrate/types.ts | 370 -- .../extensions/gsd/migrate/validator.ts | 55 - .../extensions/gsd/migrate/writer.ts | 579 --- .../extensions/gsd/milestone-actions.ts | 169 - .../extensions/gsd/milestone-id-utils.ts | 32 - src/resources/extensions/gsd/milestone-ids.ts | 136 - .../gsd/milestone-validation-gates.ts | 53 - .../extensions/gsd/model-cost-table.ts | 84 - src/resources/extensions/gsd/model-router.ts | 611 --- .../extensions/gsd/namespaced-registry.ts | 467 --- .../extensions/gsd/namespaced-resolver.ts | 307 -- .../extensions/gsd/native-git-bridge.ts | 1222 ------ .../extensions/gsd/native-parser-bridge.ts | 267 -- .../extensions/gsd/notification-overlay.ts | 328 -- .../extensions/gsd/notification-store.ts | 342 -- .../extensions/gsd/notification-widget.ts | 60 - src/resources/extensions/gsd/notifications.ts | 135 - .../extensions/gsd/observability-validator.ts | 456 --- src/resources/extensions/gsd/package.json | 11 - .../extensions/gsd/parallel-eligibility.ts | 242 -- .../extensions/gsd/parallel-merge.ts | 242 -- .../gsd/parallel-monitor-overlay.ts | 506 --- .../extensions/gsd/parallel-orchestrator.ts | 1064 ------ .../extensions/gsd/parsers-legacy.ts | 292 -- src/resources/extensions/gsd/paths.ts | 563 --- src/resources/extensions/gsd/phase-anchor.ts | 71 - .../extensions/gsd/plugin-importer.ts | 411 -- .../extensions/gsd/post-execution-checks.ts | 539 --- .../extensions/gsd/post-unit-hooks.ts | 86 - .../extensions/gsd/pre-execution-checks.ts | 638 ---- .../extensions/gsd/preferences-models.ts | 471 --- .../extensions/gsd/preferences-skills.ts | 146 - .../extensions/gsd/preferences-types.ts | 457 --- .../extensions/gsd/preferences-validation.ts | 1131 ------ src/resources/extensions/gsd/preferences.ts | 634 ---- src/resources/extensions/gsd/preparation.ts | 1419 ------- .../extensions/gsd/progress-score.ts | 161 - .../extensions/gsd/prompt-cache-optimizer.ts | 213 -- src/resources/extensions/gsd/prompt-loader.ts | 183 - .../extensions/gsd/prompt-ordering.ts | 200 - .../extensions/gsd/prompt-validation.ts | 157 - .../extensions/gsd/prompts/add-tests.md | 35 - .../gsd/prompts/complete-milestone.md | 68 - .../extensions/gsd/prompts/complete-slice.md | 44 - .../gsd/prompts/discuss-headless.md | 253 -- .../extensions/gsd/prompts/discuss.md | 423 --- .../extensions/gsd/prompts/doctor-heal.md | 30 - .../extensions/gsd/prompts/execute-task.md | 85 - .../extensions/gsd/prompts/forensics.md | 198 - .../extensions/gsd/prompts/gate-evaluate.md | 32 - .../gsd/prompts/guided-complete-slice.md | 3 - .../gsd/prompts/guided-discuss-milestone.md | 117 - .../gsd/prompts/guided-discuss-slice.md | 67 - .../gsd/prompts/guided-execute-task.md | 3 - .../gsd/prompts/guided-plan-milestone.md | 30 - .../gsd/prompts/guided-plan-slice.md | 3 - .../gsd/prompts/guided-research-slice.md | 15 - .../gsd/prompts/guided-resume-task.md | 1 - .../extensions/gsd/prompts/heal-skill.md | 45 - .../gsd/prompts/parallel-research-slices.md | 23 - .../extensions/gsd/prompts/plan-milestone.md | 108 - .../extensions/gsd/prompts/plan-slice.md | 89 - src/resources/extensions/gsd/prompts/queue.md | 135 - .../extensions/gsd/prompts/quick-task.md | 44 - .../gsd/prompts/reactive-execute.md | 44 - .../gsd/prompts/reassess-roadmap.md | 68 - .../extensions/gsd/prompts/replan-slice.md | 39 - .../gsd/prompts/research-milestone.md | 47 - .../extensions/gsd/prompts/research-slice.md | 57 - .../extensions/gsd/prompts/rethink.md | 95 - .../gsd/prompts/review-migration.md | 66 - .../extensions/gsd/prompts/rewrite-docs.md | 31 - .../extensions/gsd/prompts/run-uat.md | 89 - .../extensions/gsd/prompts/system.md | 221 -- .../extensions/gsd/prompts/triage-captures.md | 68 - .../gsd/prompts/validate-milestone.md | 87 - .../extensions/gsd/prompts/workflow-start.md | 28 - .../extensions/gsd/prompts/worktree-merge.md | 125 - .../extensions/gsd/provider-error-pause.ts | 49 - src/resources/extensions/gsd/queue-order.ts | 230 -- .../extensions/gsd/queue-reorder-ui.ts | 277 -- src/resources/extensions/gsd/quick.ts | 262 -- .../extensions/gsd/reactive-graph.ts | 337 -- src/resources/extensions/gsd/repo-identity.ts | 657 ---- src/resources/extensions/gsd/reports.ts | 504 --- src/resources/extensions/gsd/rethink.ts | 163 - .../extensions/gsd/roadmap-mutations.ts | 134 - .../extensions/gsd/roadmap-slices.ts | 294 -- .../extensions/gsd/routing-history.ts | 286 -- src/resources/extensions/gsd/rule-registry.ts | 599 --- src/resources/extensions/gsd/rule-types.ts | 68 - src/resources/extensions/gsd/run-manager.ts | 180 - src/resources/extensions/gsd/safe-fs.ts | 48 - .../gsd/safety/content-validator.ts | 98 - .../gsd/safety/destructive-guard.ts | 49 - .../gsd/safety/evidence-collector.ts | 151 - .../gsd/safety/evidence-cross-ref.ts | 120 - .../gsd/safety/file-change-validator.ts | 111 - .../extensions/gsd/safety/git-checkpoint.ts | 106 - .../extensions/gsd/safety/safety-harness.ts | 105 - src/resources/extensions/gsd/service-tier.ts | 196 - .../extensions/gsd/session-forensics.ts | 546 --- src/resources/extensions/gsd/session-lock.ts | 668 ---- .../extensions/gsd/session-model-override.ts | 36 - .../extensions/gsd/session-status-io.ts | 179 - src/resources/extensions/gsd/shortcut-defs.ts | 56 - src/resources/extensions/gsd/skill-catalog.ts | 1088 ------ .../extensions/gsd/skill-discovery.ts | 157 - src/resources/extensions/gsd/skill-health.ts | 422 -- .../extensions/gsd/skill-telemetry.ts | 140 - .../gsd/skills/gsd-headless/SKILL.md | 242 -- .../references/answer-injection.md | 83 - .../gsd-headless/references/commands.md | 64 - .../gsd-headless/references/multi-session.md | 176 - .../extensions/gsd/slice-parallel-conflict.ts | 86 - .../gsd/slice-parallel-eligibility.ts | 73 - .../gsd/slice-parallel-orchestrator.ts | 496 --- src/resources/extensions/gsd/state.ts | 1747 --------- src/resources/extensions/gsd/status-guards.ts | 27 - .../gsd/structured-data-formatter.ts | 146 - src/resources/extensions/gsd/sync-lock.ts | 94 - .../extensions/gsd/templates/PREFERENCES.md | 98 - .../extensions/gsd/templates/context.md | 108 - .../extensions/gsd/templates/decisions.md | 8 - .../extensions/gsd/templates/knowledge.md | 19 - .../gsd/templates/milestone-summary.md | 81 - .../gsd/templates/milestone-validation.md | 74 - .../extensions/gsd/templates/plan.md | 148 - .../extensions/gsd/templates/project.md | 31 - .../extensions/gsd/templates/reassessment.md | 29 - .../extensions/gsd/templates/requirements.md | 81 - .../extensions/gsd/templates/research.md | 79 - .../extensions/gsd/templates/roadmap.md | 131 - .../extensions/gsd/templates/runtime.md | 21 - .../gsd/templates/secrets-manifest.md | 22 - .../extensions/gsd/templates/slice-context.md | 58 - .../extensions/gsd/templates/slice-summary.md | 108 - .../extensions/gsd/templates/state.md | 17 - .../extensions/gsd/templates/task-plan.md | 87 - .../extensions/gsd/templates/task-summary.md | 66 - src/resources/extensions/gsd/templates/uat.md | 54 - .../tests/active-milestone-id-guard.test.ts | 91 - .../extensions/gsd/tests/activity-log.test.ts | 175 - .../gsd/tests/agent-end-retry.test.ts | 143 - .../tests/artifact-corruption-2630.test.ts | 288 -- .../tests/ask-user-questions-dedup.test.ts | 120 - .../extensions/gsd/tests/atomic-write.test.ts | 144 - .../gsd/tests/auto-budget-alerts.test.ts | 50 - .../gsd/tests/auto-dashboard.test.ts | 262 -- .../gsd/tests/auto-lock-creation.test.ts | 213 -- .../extensions/gsd/tests/auto-loop.test.ts | 2380 ------------ .../gsd/tests/auto-milestone-target.test.ts | 61 - .../tests/auto-mode-interactive-guard.test.ts | 71 - .../gsd/tests/auto-model-selection.test.ts | 274 -- .../auto-paused-session-validation.test.ts | 129 - .../gsd/tests/auto-paused-ui-cleanup.test.ts | 27 - .../tests/auto-post-unit-step-message.test.ts | 53 - .../extensions/gsd/tests/auto-pr-bugs.test.ts | 88 - .../gsd/tests/auto-project-root-env.test.ts | 33 - .../gsd/tests/auto-recovery.test.ts | 714 ---- .../tests/auto-remediate-slice-status.test.ts | 56 - .../tests/auto-session-encapsulation.test.ts | 255 -- .../tests/auto-stale-lock-self-kill.test.ts | 87 - .../auto-start-cold-db-bootstrap.test.ts | 37 - .../tests/auto-start-model-capture.test.ts | 113 - .../tests/auto-start-needs-discussion.test.ts | 218 -- .../tests/auto-start-time-persistence.test.ts | 50 - .../tests/auto-start-worktree-db-path.test.ts | 28 - .../gsd/tests/auto-supervisor.test.mjs | 53 - .../tests/auto-worktree-auto-resolve.test.ts | 80 - .../tests/auto-wrapup-inflight-guard.test.ts | 107 - .../autocomplete-regressions-1675.test.ts | 83 - .../gsd/tests/block-db-writes.test.ts | 63 - .../bootstrap-derive-state-db-open.test.ts | 39 - .../gsd/tests/browser-teardown.test.ts | 133 - .../gsd/tests/budget-prediction.test.ts | 220 -- .../gsd/tests/bundled-workflow-defs.test.ts | 180 - .../tests/cache-staleness-regression.test.ts | 294 -- .../gsd/tests/capability-router.test.ts | 371 -- .../extensions/gsd/tests/captures.test.ts | 524 --- ...laude-import-marketplace-discovery.test.ts | 191 - .../gsd/tests/claude-import-tui.test.ts | 350 -- .../gsd/tests/claude-skill-dirs.test.ts | 51 - .../gsd/tests/clear-stale-autostart.test.ts | 41 - .../gsd/tests/cli-provider-rate-limit.test.ts | 47 - .../extensions/gsd/tests/cmux.test.ts | 339 -- .../gsd/tests/codebase-generator.test.ts | 669 ---- .../gsd/tests/cold-resume-db-reopen.test.ts | 65 - .../gsd/tests/collect-from-manifest.test.ts | 506 --- .../gsd/tests/collision-diagnostics.test.ts | 705 ---- .../gsd/tests/commands-backlog.test.ts | 158 - .../gsd/tests/commands-config.test.ts | 24 - .../extensions/gsd/tests/commands-do.test.ts | 127 - .../tests/commands-extract-learnings.test.ts | 340 -- .../tests/commands-inspect-open-db.test.ts | 46 - .../gsd/tests/commands-logs.test.ts | 241 -- .../gsd/tests/commands-pr-branch.test.ts | 68 - .../gsd/tests/commands-session-report.test.ts | 82 - .../gsd/tests/commands-ship.test.ts | 71 - .../tests/commands-workflow-custom.test.ts | 309 -- .../complete-milestone-false-merge.test.ts | 142 - .../gsd/tests/complete-milestone.test.ts | 451 --- .../tests/complete-slice-gate-closure.test.ts | 167 - ...e-slice-prompt-task-summary-layout.test.ts | 18 - .../complete-slice-string-coercion.test.ts | 247 -- .../complete-slice-verification-gate.test.ts | 72 - .../gsd/tests/complete-slice.test.ts | 432 --- .../complete-task-normalize-lists.test.ts | 54 - .../complete-task-rollback-evidence.test.ts | 106 - .../gsd/tests/complete-task.test.ts | 493 --- .../gsd/tests/completed-at-reconcile.test.ts | 42 - .../completed-units-metrics-sync.test.ts | 111 - .../tests/completion-hierarchy-guards.test.ts | 192 - .../gsd/tests/complexity-classifier.test.ts | 206 - .../gsd/tests/context-budget.test.ts | 352 -- .../gsd/tests/context-injector.test.ts | 313 -- .../gsd/tests/context-masker.test.ts | 122 - .../gsd/tests/context-store.test.ts | 630 --- .../copy-planning-artifacts-samepath.test.ts | 21 - .../gsd/tests/core-overlay-fallback.test.ts | 177 - .../gsd/tests/cost-projection.test.ts | 120 - .../gsd/tests/crash-handler-secondary.test.ts | 235 -- .../gsd/tests/crash-recovery.test.ts | 500 --- .../custom-engine-loop-integration.test.ts | 541 --- .../gsd/tests/custom-verification.test.ts | 415 -- .../gsd/tests/custom-workflow-engine.test.ts | 370 -- .../gsd/tests/dashboard-budget.test.ts | 329 -- .../gsd/tests/dashboard-custom-engine.test.ts | 87 - .../dashboard-model-label-ordering.test.ts | 107 - .../gsd/tests/db-access-guardrails.test.ts | 109 - .../tests/db-path-worktree-symlink.test.ts | 135 - .../extensions/gsd/tests/db-writer.test.ts | 831 ---- .../extensions/gsd/tests/debug-logger.test.ts | 185 - .../gsd/tests/decision-scope-cascade.test.ts | 370 -- .../gsd/tests/defer-milestone-stamp.test.ts | 30 - .../gsd/tests/deferred-slice-dispatch.test.ts | 203 - .../gsd/tests/definition-io.test.ts | 57 - .../gsd/tests/definition-loader.test.ts | 762 ---- .../gsd/tests/derive-state-crossval.test.ts | 513 --- .../derive-state-db-disk-reconcile.test.ts | 121 - .../gsd/tests/derive-state-db.test.ts | 1129 ------ .../gsd/tests/derive-state-deps.test.ts | 641 ---- .../gsd/tests/derive-state-draft.test.ts | 310 -- .../gsd/tests/derive-state-helpers.test.ts | 496 --- .../extensions/gsd/tests/derive-state.test.ts | 982 ----- .../extensions/gsd/tests/detection.test.ts | 1227 ------ .../gsd/tests/dev-engine-wrapper.test.ts | 314 -- .../extensions/gsd/tests/diff-context.test.ts | 136 - .../gsd/tests/discord-invite-links.test.ts | 47 - .../tests/discuss-empty-db-fallback.test.ts | 127 - .../discuss-incremental-persistence.test.ts | 45 - .../gsd/tests/discuss-prompt.test.ts | 15 - .../tests/discuss-queued-milestones.test.ts | 281 -- ...discuss-slice-structured-questions.test.ts | 46 - .../gsd/tests/discuss-tool-scope-leak.test.ts | 76 - .../gsd/tests/discuss-tool-scoping.test.ts | 130 - .../dispatch-guard-closed-status.test.ts | 33 - .../gsd/tests/dispatch-guard.test.ts | 318 -- .../tests/dispatch-missing-task-plans.test.ts | 126 - .../tests/dispatch-uat-last-completed.test.ts | 172 - .../tests/dispatcher-stuck-planning.test.ts | 37 - .../extensions/gsd/tests/dist-redirect.mjs | 112 - .../gsd/tests/doctor-fix-flag.test.ts | 92 - .../doctor-heal-fixable-warnings.test.ts | 14 - .../gsd/tests/doctor-providers.test.ts | 639 ---- .../tests/doctor-scope-db-unavailable.test.ts | 43 - .../gsd/tests/double-merge-guard.test.ts | 97 - .../gsd/tests/draft-promotion.test.ts | 169 - .../gsd/tests/dynamic-routing-default.test.ts | 20 - .../tests/empty-content-abort-loop.test.ts | 74 - .../tests/engine-interfaces-contract.test.ts | 271 -- .../enhanced-verification-integration.test.ts | 526 --- .../gsd/tests/ensure-db-open.test.ts | 230 -- .../gsd/tests/error-success-mask.test.ts | 37 - .../gsd/tests/est-annotation-timeout.test.ts | 120 - .../tests/event-replay-idempotency.test.ts | 140 - ...ask-prompt-existing-artifact-guard.test.ts | 33 - .../extensions/gsd/tests/exit-command.test.ts | 101 - .../gsd/tests/export-html-all.test.ts | 105 - .../tests/export-html-enhancements.test.ts | 379 -- .../extension-bootstrap-isolation.test.ts | 154 - .../extension-selector-separator.test.ts | 144 - .../tests/false-degraded-mode-warning.test.ts | 104 - .../gsd/tests/file-change-validator.test.ts | 50 - .../extensions/gsd/tests/file-lock.test.ts | 103 - .../gsd/tests/files-loadfile-eisdir.test.ts | 18 - .../gsd/tests/finalize-timeout-guard.test.ts | 244 -- .../find-missing-summaries-closed.test.ts | 48 - .../extensions/gsd/tests/flag-file-db.test.ts | 278 -- .../gsd/tests/flat-rate-routing-guard.test.ts | 186 - .../tests/forensics-context-persist.test.ts | 159 - .../gsd/tests/forensics-db-completion.test.ts | 96 - .../gsd/tests/forensics-dedup.test.ts | 79 - .../gsd/tests/forensics-error-filter.test.ts | 121 - .../gsd/tests/forensics-issue-routing.test.ts | 43 - .../gsd/tests/forensics-journal.test.ts | 162 - .../gsd/tests/forensics-stuck-loops.test.ts | 165 - .../gsd/tests/format-shortcut.test.ts | 100 - .../gsd/tests/freeform-decisions.test.ts | 232 -- .../gsd/tests/frontmatter-parse-noise.test.ts | 42 - .../gsd/tests/gate-dispatch.test.ts | 216 -- .../gsd/tests/gate-registry.test.ts | 140 - .../extensions/gsd/tests/gate-storage.test.ts | 156 - .../gsd/tests/git-checkpoint.test.ts | 94 - .../gsd/tests/gitignore-bg-shell.test.ts | 38 - .../gsd/tests/graph-context.test.ts | 337 -- .../gsd/tests/graph-operations.test.ts | 593 --- .../extensions/gsd/tests/gsd-db.test.ts | 523 --- .../extensions/gsd/tests/gsd-inspect.test.ts | 114 - .../gsd/tests/gsd-no-project-error.test.ts | 73 - .../extensions/gsd/tests/gsd-recover.test.ts | 440 --- .../extensions/gsd/tests/gsd-tools.test.ts | 441 --- .../tests/gsdroot-worktree-detection.test.ts | 164 - .../tests/guided-flow-dynamic-routing.test.ts | 135 - .../guided-flow-session-isolation.test.ts | 131 - .../tests/guided-flow-state-rebuild.test.ts | 103 - .../gsd/tests/headless-answers.test.ts | 340 -- .../gsd/tests/headless-query.test.ts | 184 - .../gsd/tests/health-widget.test.ts | 224 -- .../gsd/tests/hook-key-parsing.test.ts | 107 - .../gsd/tests/hook-model-resolution.test.ts | 98 - .../idle-watchdog-stall-override.test.ts | 125 - .../gsd/tests/import-done-milestones.test.ts | 42 - .../gsd/tests/in-flight-tool-tracking.test.ts | 32 - .../extensions/gsd/tests/infra-error.test.ts | 129 - .../gsd/tests/infra-errors-cooldown.test.ts | 180 - .../extensions/gsd/tests/init-wizard.test.ts | 195 - .../gsd/tests/insert-slice-no-wipe.test.ts | 88 - .../gsd/tests/integration-edge.test.ts | 223 -- .../all-milestones-complete-merge.test.ts | 248 -- .../integration/atomic-task-closeout.test.ts | 72 - .../tests/integration/auto-preflight.test.ts | 38 - .../tests/integration/auto-recovery.test.ts | 867 ----- .../integration/auto-secrets-gate.test.ts | 194 - .../integration/auto-stash-merge.test.ts | 121 - .../auto-worktree-milestone-merge.test.ts | 857 ----- .../tests/integration/auto-worktree.test.ts | 348 -- .../tests/integration/continue-here.test.ts | 281 -- .../doctor-completion-deferral.test.ts | 88 - .../integration/doctor-delimiter-fix.test.ts | 83 - .../integration/doctor-enhancements.test.ts | 243 -- .../doctor-environment-worktree.test.ts | 164 - .../integration/doctor-environment.test.ts | 403 -- .../doctor-false-positives.test.ts | 243 -- .../tests/integration/doctor-fixlevel.test.ts | 263 -- .../gsd/tests/integration/doctor-git.test.ts | 725 ---- .../integration/doctor-proactive.test.ts | 325 -- .../doctor-roadmap-summary-atomicity.test.ts | 123 - .../tests/integration/doctor-runtime.test.ts | 377 -- .../gsd/tests/integration/doctor.test.ts | 612 --- .../e2e-workflow-pipeline-integration.test.ts | 476 --- ...ature-branch-lifecycle-integration.test.ts | 415 -- .../gsd/tests/integration/git-locale.test.ts | 119 - .../tests/integration/git-self-heal.test.ts | 131 - .../gsd/tests/integration/git-service.test.ts | 1548 -------- .../gitignore-staging-2570.test.ts | 150 - .../integration/gitignore-tracked-gsd.test.ts | 256 -- .../gsd/tests/integration/headless-command.ts | 534 --- .../tests/integration/idle-recovery.test.ts | 393 -- .../inherited-repo-home-dir.test.ts | 191 - .../integration/integration-lifecycle.test.ts | 266 -- .../integration-mixed-milestones.test.ts | 539 --- .../integration/integration-proof.test.ts | 634 ---- .../integration/merge-cwd-restore.test.ts | 169 - .../tests/integration/migrate-command.test.ts | 360 -- .../milestone-transition-worktree.test.ts | 166 - .../tests/integration/parallel-merge.test.ts | 577 --- ...rallel-workers-multi-milestone-e2e.test.ts | 337 -- .../gsd/tests/integration/paths.test.ts | 98 - .../integration/plugin-importer-live.test.ts | 481 --- .../queue-completed-milestone-perf.test.ts | 155 - .../integration/queue-reorder-e2e.test.ts | 335 -- .../quick-branch-lifecycle.test.ts | 253 -- .../gsd/tests/integration/run-uat.test.ts | 609 --- .../state-machine-edge-cases.test.ts | 1192 ------ .../state-machine-live-validation.test.ts | 957 ----- .../state-machine-runtime-failures.test.ts | 841 ---- .../tests/integration/token-savings.test.ts | 364 -- .../tests/integration/worktree-e2e.test.ts | 237 -- .../tests/interactive-routing-bypass.test.ts | 207 - .../interactive-tool-idle-exemption.test.ts | 119 - .../tests/interrupted-session-auto.test.ts | 146 - .../gsd/tests/interrupted-session-ui.test.ts | 136 - .../tests/isolation-none-branch-guard.test.ts | 62 - .../tests/iterate-engine-integration.test.ts | 429 --- .../gsd/tests/journal-integration.test.ts | 669 ---- .../gsd/tests/journal-query-tool.test.ts | 147 - .../extensions/gsd/tests/journal.test.ts | 341 -- .../gsd/tests/json-persistence-atomic.test.ts | 183 - .../extensions/gsd/tests/key-manager.test.ts | 492 --- .../extensions/gsd/tests/knowledge.test.ts | 250 -- .../gsd/tests/lazy-pi-tui-import.test.ts | 15 - .../gsd/tests/manifest-status.test.ts | 274 -- .../gsd/tests/markdown-renderer.test.ts | 1161 ------ .../gsd/tests/marketplace-test-fixtures.ts | 91 - .../gsd/tests/mcp-project-config.test.ts | 89 - .../extensions/gsd/tests/mcp-status.test.ts | 118 - .../extensions/gsd/tests/md-importer.test.ts | 415 -- .../extensions/gsd/tests/measurement.test.ts | 531 --- .../gsd/tests/memory-extractor.test.ts | 254 -- .../gsd/tests/memory-leak-guards.test.ts | 91 - .../tests/memory-pressure-stuck-state.test.ts | 54 - .../extensions/gsd/tests/memory-store.test.ts | 331 -- .../tests/merge-conflict-stops-loop.test.ts | 66 - .../extensions/gsd/tests/metrics.test.ts | 499 --- .../tests/migrate-external-worktree.test.ts | 105 - .../gsd/tests/migrate-hierarchy.test.ts | 429 --- .../gsd/tests/migrate-parser.test.ts | 748 ---- .../gsd/tests/migrate-transformer.test.ts | 619 --- .../tests/migrate-validator-parsers.test.ts | 390 -- .../tests/migrate-writer-integration.test.ts | 294 -- .../gsd/tests/migrate-writer.test.ts | 361 -- .../tests/milestone-id-reservation.test.ts | 73 - .../gsd/tests/milestone-report-path.test.ts | 51 - .../milestone-status-authoritative.test.ts | 116 - .../gsd/tests/milestone-status-tool.test.ts | 201 - ...milestone-transition-state-rebuild.test.ts | 130 - .../gsd/tests/model-cost-table.test.ts | 103 - .../gsd/tests/model-isolation.test.ts | 305 -- .../extensions/gsd/tests/model-router.test.ts | 758 ---- .../gsd/tests/model-unittype-mapping.test.ts | 220 -- .../gsd/tests/must-have-parser.test.ts | 278 -- .../gsd/tests/namespaced-registry.test.ts | 1027 ----- .../gsd/tests/namespaced-resolver.test.ts | 671 ---- .../native-git-bridge-exec-fallback.test.ts | 140 - .../tests/native-has-changes-cache.test.ts | 61 - .../needs-remediation-revalidation.test.ts | 48 - .../gsd/tests/next-milestone-id.test.ts | 23 - .../gsd/tests/none-mode-gates.test.ts | 152 - .../gsd/tests/note-captures-executed.test.ts | 46 - .../gsd/tests/notification-overlay.test.ts | 73 - .../gsd/tests/notification-store.test.ts | 317 -- .../gsd/tests/notification-widget.test.ts | 26 - .../gsd/tests/notifications-handler.test.ts | 90 - .../gsd/tests/notifications.test.ts | 134 - .../gsd/tests/orphaned-worktree-audit.test.ts | 189 - .../extensions/gsd/tests/overrides.test.ts | 124 - .../tests/parallel-budget-atomicity.test.ts | 330 -- .../gsd/tests/parallel-commit-scope.test.ts | 159 - .../gsd/tests/parallel-crash-recovery.test.ts | 284 -- .../tests/parallel-eligibility-ghost.test.ts | 150 - .../tests/parallel-monitor-overlay.test.ts | 82 - .../gsd/tests/parallel-orchestration.test.ts | 736 ---- ...rallel-orchestrator-zombie-cleanup.test.ts | 277 -- .../tests/parallel-research-dispatch.test.ts | 146 - .../parallel-worker-lock-contention.test.ts | 226 -- .../tests/parallel-worker-monitoring.test.ts | 199 - .../extensions/gsd/tests/park-db-sync.test.ts | 103 - .../gsd/tests/park-edge-cases.test.ts | 253 -- .../gsd/tests/park-milestone.test.ts | 418 -- .../extensions/gsd/tests/parsers.test.ts | 1892 --------- .../gsd/tests/phantom-ghost-detection.test.ts | 55 - .../phantom-milestone-default-queued.test.ts | 39 - .../extensions/gsd/tests/phase-anchor.test.ts | 83 - .../phases-merge-error-stops-auto.test.ts | 103 - ...an-milestone-artifact-verification.test.ts | 62 - .../plan-milestone-queue-context.test.ts | 48 - .../gsd/tests/plan-milestone-title.test.ts | 71 - .../gsd/tests/plan-milestone.test.ts | 295 -- .../gsd/tests/plan-quality-validator.test.ts | 474 --- .../gsd/tests/plan-slice-prompt.test.ts | 298 -- .../extensions/gsd/tests/plan-slice.test.ts | 179 - .../extensions/gsd/tests/plan-task.test.ts | 145 - .../gsd/tests/planning-crossval.test.ts | 305 -- .../gsd/tests/plugin-importer.test.ts | 1383 ------- .../gsd/tests/post-exec-retry-bypass.test.ts | 390 -- .../gsd/tests/post-execution-checks.test.ts | 813 ---- .../gsd/tests/post-mutation-hook.test.ts | 171 - .../gsd/tests/post-unit-hooks.test.ts | 300 -- .../gsd/tests/post-unit-state-rebuild.test.ts | 35 - .../gsd/tests/pre-exec-backtick-strip.test.ts | 115 - .../gsd/tests/pre-execution-checks.test.ts | 1312 ------- .../tests/pre-execution-fail-closed.test.ts | 266 -- .../tests/pre-execution-pause-wiring.test.ts | 496 --- .../gsd/tests/preferences-formatting.test.ts | 87 - .../tests/preferences-worktree-sync.test.ts | 133 - .../extensions/gsd/tests/preferences.test.ts | 672 ---- .../preflight-context-draft-filter.test.ts | 115 - .../tests/project-relocation-recovery.test.ts | 297 -- .../gsd/tests/project-root-cwd-crash.test.ts | 53 - .../projection-no-plan-overwrite.test.ts | 83 - .../gsd/tests/projection-regression.test.ts | 269 -- .../tests/prompt-budget-enforcement.test.ts | 464 --- .../gsd/tests/prompt-cache-optimizer.test.ts | 314 -- .../gsd/tests/prompt-contracts.test.ts | 315 -- .../extensions/gsd/tests/prompt-db.test.ts | 387 -- .../tests/prompt-loader-replacement.test.ts | 178 - .../prompt-loader-working-directory.test.ts | 19 - .../gsd/tests/prompt-ordering.test.ts | 296 -- .../gsd/tests/prompt-step-ordering.test.ts | 85 - .../tests/prompt-system-gate-coverage.test.ts | 208 - .../gsd/tests/prompt-tool-names.test.ts | 69 - .../gsd/tests/provider-errors.test.ts | 556 --- .../gsd/tests/quality-gates.test.ts | 347 -- .../gsd/tests/query-tools-db-open.test.ts | 47 - .../gsd/tests/queue-draft-detection.test.ts | 100 - .../gsd/tests/queue-execution-guard.test.ts | 166 - .../extensions/gsd/tests/queue-order.test.ts | 192 - .../tests/queued-discuss-fast-path.test.ts | 107 - .../gsd/tests/quick-auto-guard.test.ts | 100 - .../gsd/tests/quick-turn-end-cleanup.test.ts | 90 - .../tests/rate-limit-model-fallback.test.ts | 90 - .../gsd/tests/reactive-executor.test.ts | 511 --- .../gsd/tests/reactive-graph.test.ts | 363 -- .../gsd/tests/reassess-detection.test.ts | 154 - .../gsd/tests/reassess-handler.test.ts | 442 --- .../gsd/tests/reassess-prompt.test.ts | 135 - .../tests/reconciliation-edge-cases.test.ts | 162 - .../gsd/tests/recovery-attempts-reset.test.ts | 176 - .../gsd/tests/regex-hardening.test.ts | 281 -- .../tests/register-extension-guard.test.ts | 59 - .../register-hooks-depth-verification.test.ts | 97 - .../gsd/tests/register-shortcuts.test.ts | 131 - .../remediation-completion-guard.test.ts | 110 - .../gsd/tests/remote-questions.test.ts | 874 ----- .../gsd/tests/remote-status.test.ts | 99 - .../extensions/gsd/tests/reopen-slice.test.ts | 155 - .../extensions/gsd/tests/reopen-task.test.ts | 165 - .../gsd/tests/replan-handler.test.ts | 410 -- .../extensions/gsd/tests/replan-slice.test.ts | 606 --- .../gsd/tests/repo-identity-worktree.test.ts | 231 -- .../extensions/gsd/tests/requirements.test.ts | 101 - .../extensions/gsd/tests/resolve-ts-hooks.mjs | 23 - .../extensions/gsd/tests/resolve-ts.mjs | 5 - .../tests/resource-loader-import-path.test.ts | 38 - .../tests/restore-tools-after-discuss.test.ts | 63 - .../tests/retry-diagnostic-reasoning.test.ts | 161 - .../gsd/tests/retry-state-reset.test.ts | 305 -- .../gsd/tests/rewrite-count-persist.test.ts | 82 - .../tests/roadmap-parse-regression.test.ts | 399 -- .../gsd/tests/roadmap-slices.test.ts | 464 --- .../gsd/tests/rogue-file-detection.test.ts | 295 -- .../gsd/tests/routing-history.test.ts | 229 -- .../gsd/tests/rule-registry.test.ts | 411 -- .../extensions/gsd/tests/run-manager.test.ts | 229 -- .../gsd/tests/run-uat-replay-cap.test.ts | 51 - .../gsd/tests/schema-v9-sequence.test.ts | 176 - .../gsd/tests/secure-env-collect.test.ts | 364 -- .../extensions/gsd/tests/service-tier.test.ts | 127 - .../gsd/tests/session-lock-multipath.test.ts | 166 - .../gsd/tests/session-lock-regression.test.ts | 315 -- .../tests/session-lock-transient-read.test.ts | 224 -- .../gsd/tests/session-model-override.test.ts | 35 - .../extensions/gsd/tests/shared-wal.test.ts | 239 -- .../gsd/tests/show-config-command.test.ts | 56 - .../gsd/tests/sidecar-queue.test.ts | 181 - .../gsd/tests/signal-handlers.test.ts | 103 - .../tests/silent-catch-diagnostics.test.ts | 284 -- .../gsd/tests/single-writer-invariant.test.ts | 180 - .../gsd/tests/skill-activation.test.ts | 233 -- .../gsd/tests/skill-catalog.test.ts | 193 - .../gsd/tests/skill-lifecycle.test.ts | 126 - .../tests/skip-slice-state-rebuild.test.ts | 31 - .../skipped-validation-completion.test.ts | 39 - .../gsd/tests/slice-context-injection.test.ts | 50 - .../gsd/tests/slice-disk-reconcile.test.ts | 233 -- .../gsd/tests/slice-parallel-conflict.test.ts | 92 - .../tests/slice-parallel-eligibility.test.ts | 95 - .../tests/slice-parallel-orchestrator.test.ts | 83 - .../gsd/tests/slice-sequence-insert.test.ts | 51 - .../gsd/tests/smart-entry-complete.test.ts | 53 - .../gsd/tests/smart-entry-draft.test.ts | 123 - .../gsd/tests/sqlite-unavailable-gate.test.ts | 65 - .../gsd/tests/stale-lockfile-recovery.test.ts | 36 - .../stale-milestone-id-reservation.test.ts | 79 - .../gsd/tests/stale-queued-milestone.test.ts | 147 - .../gsd/tests/stale-slice-rows.test.ts | 41 - .../gsd/tests/stale-worktree-cwd.test.ts | 152 - .../gsd/tests/stalled-tool-recovery.test.ts | 100 - .../gsd/tests/start-auto-detached.test.ts | 90 - .../gsd/tests/stash-pop-gsd-conflict.test.ts | 146 - .../tests/stash-queued-context-files.test.ts | 326 -- .../gsd/tests/state-corruption-2945.test.ts | 405 -- .../gsd/tests/state-derivation-parity.test.ts | 257 -- .../state-machine-full-walkthrough.test.ts | 1625 -------- .../gsd/tests/status-db-open.test.ts | 47 - .../gsd/tests/status-guards.test.ts | 34 - .../gsd/tests/steer-worktree-path.test.ts | 108 - .../gsd/tests/stop-auto-merge-back.test.ts | 67 - .../tests/stop-auto-race-null-unit.test.ts | 106 - .../gsd/tests/stop-auto-remote.test.ts | 158 - .../gsd/tests/stop-backtrack.test.ts | 216 -- .../tests/structured-data-formatter.test.ts | 366 -- .../tests/stuck-detection-coverage.test.ts | 217 -- .../tests/subagent-agent-discovery.test.ts | 91 - .../gsd/tests/subagent-model-dispatch.test.ts | 267 -- .../gsd/tests/summary-render-parity.test.ts | 221 -- .../tests/survivor-branch-complete.test.ts | 108 - .../tests/symlink-extension-discovery.test.ts | 125 - .../tests/symlink-numbered-variants.test.ts | 145 - .../extensions/gsd/tests/sync-lock.test.ts | 122 - .../tests/sync-worktree-skip-current.test.ts | 65 - .../gsd/tests/terminated-transient.test.ts | 128 - .../extensions/gsd/tests/test-helpers.ts | 61 - .../extensions/gsd/tests/test-utils.ts | 165 - .../gsd/tests/token-cost-display.test.ts | 118 - .../gsd/tests/token-counter.test.ts | 129 - .../gsd/tests/token-profile.test.ts | 271 -- .../gsd/tests/tool-call-loop-guard.test.ts | 179 - .../gsd/tests/tool-compatibility.test.ts | 199 - .../tool-invocation-error-loop-break.test.ts | 138 - .../extensions/gsd/tests/tool-naming.test.ts | 125 - .../gsd/tests/tool-param-optionality.test.ts | 349 -- .../gsd/tests/triage-dispatch.test.ts | 345 -- .../gsd/tests/triage-resolution.test.ts | 564 --- .../uat-stuck-loop-orphaned-worktree.test.ts | 289 -- .../gsd/tests/unborn-branch.test.ts | 85 - .../extensions/gsd/tests/undo.test.ts | 462 --- .../gsd/tests/unique-milestone-ids.test.ts | 203 - .../gsd/tests/unit-ownership.test.ts | 258 -- .../extensions/gsd/tests/unit-runtime.test.ts | 257 -- ...uctured-continue-context-injection.test.ts | 163 - .../gsd/tests/uok-audit-unified.test.ts | 101 - .../gsd/tests/uok-contracts.test.ts | 85 - .../gsd/tests/uok-execution-graph.test.ts | 69 - .../extensions/gsd/tests/uok-flags.test.ts | 39 - .../gsd/tests/uok-gate-runner.test.ts | 70 - .../gsd/tests/uok-gitops-turn-action.test.ts | 85 - .../gsd/tests/uok-gitops-wiring.test.ts | 35 - .../gsd/tests/uok-model-policy.test.ts | 89 - .../gsd/tests/uok-plan-v2-wiring.test.ts | 167 - .../gsd/tests/uok-preferences.test.ts | 66 - .../gsd/tests/update-command.test.ts | 86 - .../gsd/tests/vacuous-truth-slices.test.ts | 115 - .../gsd/tests/vacuum-recovery.test.ts | 154 - .../gsd/tests/validate-directory.test.ts | 269 -- ...estone-prompt-verification-classes.test.ts | 18 - .../validate-milestone-stuck-guard.test.ts | 179 - .../validate-milestone-write-order.test.ts | 154 - .../gsd/tests/validate-milestone.test.ts | 504 --- .../tests/validation-gate-patterns.test.ts | 166 - .../extensions/gsd/tests/validation.test.ts | 72 - .../gsd/tests/verdict-parser.test.ts | 156 - .../gsd/tests/verification-evidence.test.ts | 601 --- .../gsd/tests/verification-gate.test.ts | 999 ----- .../verification-operational-gate.test.ts | 108 - .../tests/verify-artifact-tightened.test.ts | 89 - .../tests/visualizer-critical-path.test.ts | 143 - .../gsd/tests/visualizer-data.test.ts | 444 --- .../gsd/tests/visualizer-overlay.test.ts | 294 -- .../gsd/tests/visualizer-views.test.ts | 716 ---- .../tests/wave1-critical-regressions.test.ts | 49 - .../tests/wave2-events-regressions.test.ts | 48 - .../tests/wave3-session-regressions.test.ts | 47 - .../wave4-write-safety-regressions.test.ts | 70 - .../wave5-consistency-regressions.test.ts | 165 - .../tests/windows-path-normalization.test.ts | 97 - .../gsd/tests/worker-model-override.test.ts | 48 - .../gsd/tests/worker-registry.test.ts | 146 - .../gsd/tests/workflow-events.test.ts | 205 - .../gsd/tests/workflow-logger-audit.test.ts | 123 - .../gsd/tests/workflow-logger-wiring.test.ts | 223 -- .../gsd/tests/workflow-logger.test.ts | 395 -- .../gsd/tests/workflow-manifest.test.ts | 278 -- .../gsd/tests/workflow-mcp-auto-prep.test.ts | 76 - .../extensions/gsd/tests/workflow-mcp.test.ts | 695 ---- .../gsd/tests/workflow-projections.test.ts | 173 - .../gsd/tests/workflow-reconcile.test.ts | 91 - .../gsd/tests/workflow-templates.test.ts | 171 - .../gsd/tests/workflow-tool-executors.test.ts | 647 ---- .../gsd/tests/workspace-index.test.ts | 38 - .../gsd/tests/worktree-bugfix.test.ts | 117 - .../gsd/tests/worktree-db-integration.test.ts | 202 - .../worktree-db-respawn-truncation.test.ts | 219 -- .../gsd/tests/worktree-db-same-file.test.ts | 175 - .../extensions/gsd/tests/worktree-db.test.ts | 445 --- .../tests/worktree-expected-warnings.test.ts | 38 - .../tests/worktree-health-dispatch.test.ts | 175 - .../tests/worktree-health-monorepo.test.ts | 73 - .../gsd/tests/worktree-health.test.ts | 181 - .../gsd/tests/worktree-integration.test.ts | 216 -- .../gsd/tests/worktree-journal-events.test.ts | 220 -- .../gsd/tests/worktree-main-branch.test.ts | 20 - .../gsd/tests/worktree-manager.test.ts | 238 -- .../tests/worktree-nested-git-safety.test.ts | 101 - .../tests/worktree-post-create-hook.test.ts | 165 - .../tests/worktree-preferences-sync.test.ts | 155 - .../gsd/tests/worktree-resolver.test.ts | 996 ----- .../tests/worktree-submodule-safety.test.ts | 65 - .../tests/worktree-symlink-removal.test.ts | 133 - .../tests/worktree-sync-milestones.test.ts | 616 --- .../worktree-sync-overwrite-loop.test.ts | 204 - .../gsd/tests/worktree-sync-tasks.test.ts | 210 - .../tests/worktree-teardown-safety.test.ts | 148 - .../extensions/gsd/tests/worktree.test.ts | 296 -- .../extensions/gsd/tests/write-gate.test.ts | 490 --- .../gsd/tests/write-intercept.test.ts | 76 - .../tests/zero-slice-roadmap-guided.test.ts | 19 - .../gsd/tests/zombie-gsd-state.test.ts | 95 - src/resources/extensions/gsd/token-counter.ts | 65 - .../gsd/tools/complete-milestone.ts | 250 -- .../extensions/gsd/tools/complete-slice.ts | 459 --- .../extensions/gsd/tools/complete-task.ts | 339 -- .../extensions/gsd/tools/plan-milestone.ts | 328 -- .../extensions/gsd/tools/plan-slice.ts | 252 -- .../extensions/gsd/tools/plan-task.ts | 151 - .../extensions/gsd/tools/reassess-roadmap.ts | 289 -- .../extensions/gsd/tools/reopen-milestone.ts | 152 - .../extensions/gsd/tools/reopen-slice.ts | 152 - .../extensions/gsd/tools/reopen-task.ts | 146 - .../extensions/gsd/tools/replan-slice.ts | 242 -- .../gsd/tools/validate-milestone.ts | 200 - .../gsd/tools/workflow-tool-executors.ts | 659 ---- .../extensions/gsd/triage-resolution.ts | 578 --- src/resources/extensions/gsd/triage-ui.ts | 196 - src/resources/extensions/gsd/types.ts | 646 ---- src/resources/extensions/gsd/undo.ts | 465 --- src/resources/extensions/gsd/unit-id.ts | 14 - .../extensions/gsd/unit-ownership.ts | 275 -- src/resources/extensions/gsd/unit-runtime.ts | 189 - .../extensions/gsd/uok/audit-toggle.ts | 11 - src/resources/extensions/gsd/uok/audit.ts | 51 - src/resources/extensions/gsd/uok/contracts.ts | 135 - .../extensions/gsd/uok/execution-graph.ts | 241 -- src/resources/extensions/gsd/uok/flags.ts | 45 - .../extensions/gsd/uok/gate-runner.ts | 146 - src/resources/extensions/gsd/uok/gitops.ts | 75 - src/resources/extensions/gsd/uok/kernel.ts | 105 - .../extensions/gsd/uok/loop-adapter.ts | 162 - .../extensions/gsd/uok/model-policy.ts | 112 - src/resources/extensions/gsd/uok/plan-v2.ts | 156 - .../extensions/gsd/validate-directory.ts | 186 - src/resources/extensions/gsd/validation.ts | 23 - .../extensions/gsd/verdict-parser.ts | 110 - .../extensions/gsd/verification-evidence.ts | 270 -- .../extensions/gsd/verification-gate.ts | 634 ---- .../extensions/gsd/visualizer-data.ts | 953 ----- .../extensions/gsd/visualizer-overlay.ts | 570 --- .../extensions/gsd/visualizer-views.ts | 1229 ------ .../extensions/gsd/watch/header-renderer.ts | 275 -- .../extensions/gsd/workflow-engine.ts | 38 - .../extensions/gsd/workflow-events.ts | 166 - .../extensions/gsd/workflow-logger.ts | 351 -- .../extensions/gsd/workflow-manifest.ts | 256 -- .../extensions/gsd/workflow-mcp-auto-prep.ts | 76 - src/resources/extensions/gsd/workflow-mcp.ts | 389 -- .../extensions/gsd/workflow-migration.ts | 339 -- .../extensions/gsd/workflow-projections.ts | 490 --- .../extensions/gsd/workflow-reconcile.ts | 681 ---- .../extensions/gsd/workflow-templates.ts | 261 -- .../gsd/workflow-templates/bugfix.md | 87 - .../gsd/workflow-templates/dep-upgrade.md | 74 - .../gsd/workflow-templates/full-project.md | 41 - .../gsd/workflow-templates/hotfix.md | 45 - .../gsd/workflow-templates/refactor.md | 83 - .../gsd/workflow-templates/registry.json | 85 - .../gsd/workflow-templates/security-audit.md | 73 - .../gsd/workflow-templates/small-feature.md | 81 - .../gsd/workflow-templates/spike.md | 69 - .../extensions/gsd/workspace-index.ts | 272 -- .../gsd/worktree-command-bootstrap.ts | 46 - .../extensions/gsd/worktree-command.ts | 846 ----- .../extensions/gsd/worktree-health.ts | 178 - .../extensions/gsd/worktree-manager.ts | 712 ---- .../extensions/gsd/worktree-resolver.ts | 641 ---- src/resources/extensions/gsd/worktree.ts | 346 -- .../extensions/gsd/write-intercept.ts | 99 - src/resources/extensions/sf/activity-log.ts | 4 +- src/resources/extensions/sf/auto-dashboard.ts | 10 +- .../extensions/sf/auto-direct-dispatch.ts | 6 +- src/resources/extensions/sf/auto-dispatch.ts | 14 +- .../extensions/sf/auto-model-selection.ts | 2 +- src/resources/extensions/sf/auto-post-unit.ts | 26 +- src/resources/extensions/sf/auto-prompts.ts | 8 +- src/resources/extensions/sf/auto-recovery.ts | 24 +- src/resources/extensions/sf/auto-start.ts | 56 +- .../extensions/sf/auto-timeout-recovery.ts | 6 +- src/resources/extensions/sf/auto-timers.ts | 4 +- .../extensions/sf/auto-verification.ts | 8 +- src/resources/extensions/sf/auto-worktree.ts | 70 +- src/resources/extensions/sf/auto.ts | 56 +- src/resources/extensions/sf/auto/loop-deps.ts | 12 +- src/resources/extensions/sf/auto/loop.ts | 2 +- src/resources/extensions/sf/auto/phases.ts | 36 +- src/resources/extensions/sf/auto/run-unit.ts | 2 +- src/resources/extensions/sf/auto/session.ts | 2 +- src/resources/extensions/sf/auto/types.ts | 6 +- .../sf/bootstrap/agent-end-recovery.ts | 6 +- .../extensions/sf/bootstrap/db-tools.ts | 2 +- .../extensions/sf/bootstrap/dynamic-tools.ts | 32 +- .../extensions/sf/bootstrap/query-tools.ts | 4 +- .../sf/bootstrap/register-extension.ts | 14 +- .../extensions/sf/bootstrap/register-hooks.ts | 12 +- .../sf/bootstrap/register-shortcuts.ts | 14 +- .../extensions/sf/bootstrap/system-context.ts | 10 +- .../extensions/sf/bootstrap/write-gate.ts | 6 +- .../extensions/sf/branch-patterns.ts | 18 +- src/resources/extensions/sf/changelog.ts | 4 +- .../extensions/sf/codebase-generator.ts | 10 +- .../extensions/sf/commands-add-tests.ts | 6 +- .../extensions/sf/commands-backlog.ts | 10 +- .../extensions/sf/commands-bootstrap.ts | 12 +- src/resources/extensions/sf/commands-cmux.ts | 4 +- .../extensions/sf/commands-codebase.ts | 10 +- src/resources/extensions/sf/commands-do.ts | 24 +- .../extensions/sf/commands-extensions.ts | 16 +- .../sf/commands-extract-learnings.ts | 8 +- .../extensions/sf/commands-handlers.ts | 34 +- .../extensions/sf/commands-inspect.ts | 12 +- src/resources/extensions/sf/commands-logs.ts | 36 +- .../extensions/sf/commands-maintenance.ts | 14 +- .../extensions/sf/commands-mcp-status.ts | 24 +- .../extensions/sf/commands-pr-branch.ts | 2 +- .../extensions/sf/commands-prefs-wizard.ts | 2 +- src/resources/extensions/sf/commands-rate.ts | 4 +- .../extensions/sf/commands-session-report.ts | 2 +- src/resources/extensions/sf/commands-ship.ts | 2 +- .../sf/commands-workflow-templates.ts | 62 +- src/resources/extensions/sf/commands.ts | 6 +- .../extensions/sf/commands/catalog.ts | 6 +- .../extensions/sf/commands/context.ts | 16 +- .../extensions/sf/commands/dispatcher.ts | 8 +- .../extensions/sf/commands/handlers/auto.ts | 4 +- .../extensions/sf/commands/handlers/core.ts | 174 +- .../handlers/notifications-handler.ts | 22 +- .../extensions/sf/commands/handlers/ops.ts | 16 +- .../sf/commands/handlers/parallel.ts | 4 +- .../sf/commands/handlers/workflow.ts | 30 +- src/resources/extensions/sf/commands/index.ts | 6 +- src/resources/extensions/sf/config-overlay.ts | 6 +- src/resources/extensions/sf/crash-recovery.ts | 8 +- .../extensions/sf/dashboard-overlay.ts | 8 +- src/resources/extensions/sf/db-writer.ts | 12 +- src/resources/extensions/sf/detection.ts | 10 +- .../extensions/sf/dev-workflow-engine.ts | 24 +- src/resources/extensions/sf/diff-context.ts | 4 +- .../sf/docs/claude-marketplace-import.md | 12 +- .../sf/docs/preferences-reference.md | 14 +- .../extensions/sf/doctor-engine-checks.ts | 4 +- .../extensions/sf/doctor-environment.ts | 4 +- src/resources/extensions/sf/doctor-format.ts | 2 +- .../extensions/sf/doctor-global-checks.ts | 2 +- .../extensions/sf/doctor-proactive.ts | 6 +- .../extensions/sf/doctor-providers.ts | 6 +- .../extensions/sf/doctor-runtime-checks.ts | 4 +- src/resources/extensions/sf/doctor-types.ts | 2 +- src/resources/extensions/sf/doctor.ts | 2 +- .../extensions/sf/error-classifier.ts | 2 +- src/resources/extensions/sf/errors.ts | 6 +- src/resources/extensions/sf/export-html.ts | 16 +- src/resources/extensions/sf/export.ts | 8 +- .../extensions/sf/extension-manifest.json | 4 +- src/resources/extensions/sf/file-lock.ts | 4 +- src/resources/extensions/sf/forensics.ts | 30 +- src/resources/extensions/sf/gate-registry.ts | 10 +- src/resources/extensions/sf/git-self-heal.ts | 16 +- src/resources/extensions/sf/git-service.ts | 12 +- src/resources/extensions/sf/gitignore.ts | 6 +- .../extensions/sf/guided-flow-queue.ts | 14 +- src/resources/extensions/sf/guided-flow.ts | 110 +- .../extensions/sf/health-widget-core.ts | 6 +- src/resources/extensions/sf/health-widget.ts | 2 +- src/resources/extensions/sf/index.ts | 6 +- src/resources/extensions/sf/init-wizard.ts | 42 +- .../extensions/sf/interrupted-session.ts | 6 +- src/resources/extensions/sf/key-manager.ts | 26 +- .../sf/learning/bayesian-blender.mjs | 2 +- .../sf/learning/fallback-chain-writer.mjs | 12 +- .../learning/fallback-chain-writer.test.mjs | 12 +- .../extensions/sf/learning/hook-handler.mjs | 16 +- .../sf/learning/hook-handler.test.mjs | 10 +- .../extensions/sf/learning/index.mjs | 16 +- .../sf/learning/integration.test.mjs | 2 +- .../sf/learning/loadCapabilityOverrides.mjs | 4 +- .../sf/learning/outcome-aggregator.mjs | 4 +- .../sf/learning/outcome-recorder.mjs | 4 +- .../sf/learning/outcome-recorder.test.mjs | 2 +- .../extensions/sf/learning/outcome-schema.sql | 2 +- .../extensions/sf/mcp-project-config.ts | 10 +- src/resources/extensions/sf/md-importer.ts | 34 +- .../extensions/sf/migrate/command.ts | 18 +- src/resources/extensions/sf/migrate/index.ts | 20 +- .../extensions/sf/migrate/preview.ts | 8 +- .../extensions/sf/migrate/transformer.ts | 40 +- src/resources/extensions/sf/migrate/types.ts | 30 +- src/resources/extensions/sf/migrate/writer.ts | 52 +- .../extensions/sf/milestone-actions.ts | 2 +- .../extensions/sf/native-git-bridge.ts | 8 +- .../extensions/sf/notification-overlay.ts | 4 +- .../extensions/sf/notification-widget.ts | 6 +- src/resources/extensions/sf/package.json | 2 +- src/resources/extensions/sf/parallel-merge.ts | 6 +- .../extensions/sf/parallel-monitor-overlay.ts | 8 +- .../extensions/sf/parallel-orchestrator.ts | 4 +- src/resources/extensions/sf/paths.ts | 28 +- .../extensions/sf/plugin-importer.ts | 6 +- .../extensions/sf/preferences-models.ts | 20 +- .../extensions/sf/preferences-types.ts | 34 +- src/resources/extensions/sf/preferences.ts | 35 +- src/resources/extensions/sf/prompt-loader.ts | 8 +- .../sf/prompts/complete-milestone.md | 16 +- .../extensions/sf/prompts/complete-slice.md | 12 +- .../extensions/sf/prompts/discuss-headless.md | 22 +- .../extensions/sf/prompts/discuss.md | 28 +- .../extensions/sf/prompts/doctor-heal.md | 4 +- .../extensions/sf/prompts/execute-task.md | 6 +- .../extensions/sf/prompts/forensics.md | 24 +- .../sf/prompts/guided-complete-slice.md | 2 +- .../sf/prompts/guided-execute-task.md | 2 +- .../sf/prompts/guided-plan-milestone.md | 2 +- .../sf/prompts/guided-plan-slice.md | 2 +- .../sf/prompts/guided-research-slice.md | 2 +- .../extensions/sf/prompts/heal-skill.md | 4 +- .../sf/prompts/parallel-research-slices.md | 2 +- .../extensions/sf/prompts/plan-milestone.md | 6 +- .../extensions/sf/prompts/plan-slice.md | 6 +- src/resources/extensions/sf/prompts/queue.md | 16 +- .../extensions/sf/prompts/reassess-roadmap.md | 8 +- .../sf/prompts/research-milestone.md | 2 +- .../extensions/sf/prompts/rethink.md | 4 +- .../extensions/sf/prompts/review-migration.md | 10 +- src/resources/extensions/sf/prompts/system.md | 30 +- .../extensions/sf/prompts/triage-captures.md | 4 +- .../sf/prompts/validate-milestone.md | 6 +- .../extensions/sf/prompts/worktree-merge.md | 2 +- src/resources/extensions/sf/quick.ts | 8 +- src/resources/extensions/sf/repo-identity.ts | 2 +- src/resources/extensions/sf/reports.ts | 18 +- src/resources/extensions/sf/rethink.ts | 6 +- .../extensions/sf/routing-history.ts | 2 +- src/resources/extensions/sf/rule-registry.ts | 2 +- src/resources/extensions/sf/service-tier.ts | 24 +- src/resources/extensions/sf/session-lock.ts | 38 +- src/resources/extensions/sf/sf-db.ts | 102 +- src/resources/extensions/sf/shortcut-defs.ts | 20 +- src/resources/extensions/sf/skill-catalog.ts | 8 +- .../extensions/sf/skills/sf-headless/SKILL.md | 62 +- .../references/answer-injection.md | 4 +- .../skills/sf-headless/references/commands.md | 4 +- .../sf-headless/references/multi-session.md | 44 +- .../sf/slice-parallel-orchestrator.ts | 6 +- src/resources/extensions/sf/state.ts | 40 +- .../extensions/sf/templates/PREFERENCES.md | 2 +- .../extensions/sf/templates/context.md | 2 +- .../extensions/sf/templates/project.md | 2 +- .../tests/active-milestone-id-guard.test.ts | 2 +- .../extensions/sf/tests/activity-log.test.ts | 2 +- .../sf/tests/artifact-corruption-2630.test.ts | 8 +- .../sf/tests/auto-dashboard.test.ts | 6 +- .../sf/tests/auto-lock-creation.test.ts | 18 +- .../sf/tests/auto-model-selection.test.ts | 16 +- .../auto-paused-session-validation.test.ts | 2 +- .../sf/tests/auto-paused-ui-cleanup.test.ts | 4 +- .../tests/auto-post-unit-step-message.test.ts | 12 +- .../extensions/sf/tests/auto-recovery.test.ts | 16 +- .../tests/auto-remediate-slice-status.test.ts | 2 +- .../tests/auto-stale-lock-self-kill.test.ts | 2 +- .../auto-start-cold-db-bootstrap.test.ts | 2 +- .../sf/tests/auto-start-model-capture.test.ts | 2 +- .../tests/auto-start-needs-discussion.test.ts | 4 +- .../tests/auto-start-time-persistence.test.ts | 2 +- .../tests/auto-start-worktree-db-path.test.ts | 4 +- .../sf/tests/auto-supervisor.test.mjs | 4 +- .../tests/auto-worktree-auto-resolve.test.ts | 2 +- .../tests/auto-wrapup-inflight-guard.test.ts | 4 +- .../autocomplete-regressions-1675.test.ts | 30 +- .../sf/tests/block-db-writes.test.ts | 26 +- .../tests/cache-staleness-regression.test.ts | 2 +- ...laude-import-marketplace-discovery.test.ts | 4 +- .../sf/tests/claude-import-tui.test.ts | 4 +- .../sf/tests/clear-stale-autostart.test.ts | 2 +- .../extensions/sf/tests/cmux.test.ts | 22 +- .../sf/tests/codebase-generator.test.ts | 10 +- .../sf/tests/commands-backlog.test.ts | 2 +- .../extensions/sf/tests/commands-do.test.ts | 18 +- .../tests/commands-extract-learnings.test.ts | 4 +- .../sf/tests/commands-inspect-open-db.test.ts | 10 +- .../extensions/sf/tests/commands-logs.test.ts | 4 +- .../sf/tests/commands-pr-branch.test.ts | 2 +- .../extensions/sf/tests/commands-ship.test.ts | 2 +- .../sf/tests/commands-workflow-custom.test.ts | 24 +- .../complete-milestone-false-merge.test.ts | 12 +- .../sf/tests/complete-milestone.test.ts | 2 +- .../tests/complete-slice-gate-closure.test.ts | 4 +- .../complete-slice-string-coercion.test.ts | 4 +- .../complete-slice-verification-gate.test.ts | 2 +- .../sf/tests/complete-slice.test.ts | 6 +- .../complete-task-rollback-evidence.test.ts | 6 +- .../extensions/sf/tests/complete-task.test.ts | 6 +- .../completed-units-metrics-sync.test.ts | 10 +- .../sf/tests/crash-handler-secondary.test.ts | 22 +- .../sf/tests/crash-recovery.test.ts | 6 +- .../sf/tests/db-access-guardrails.test.ts | 22 +- .../sf/tests/db-path-worktree-symlink.test.ts | 12 +- .../extensions/sf/tests/db-writer.test.ts | 18 +- .../extensions/sf/tests/debug-logger.test.ts | 2 +- .../sf/tests/defer-milestone-stamp.test.ts | 2 +- .../sf/tests/deferred-slice-dispatch.test.ts | 2 +- .../extensions/sf/tests/definition-io.test.ts | 2 +- .../sf/tests/definition-loader.test.ts | 2 +- .../sf/tests/derive-state-crossval.test.ts | 10 +- .../derive-state-db-disk-reconcile.test.ts | 4 +- .../sf/tests/derive-state-db.test.ts | 6 +- .../sf/tests/derive-state-deps.test.ts | 2 +- .../sf/tests/derive-state-draft.test.ts | 2 +- .../sf/tests/derive-state-helpers.test.ts | 2 +- .../extensions/sf/tests/derive-state.test.ts | 2 +- .../extensions/sf/tests/detection.test.ts | 14 +- .../sf/tests/dev-engine-wrapper.test.ts | 2 +- .../tests/discuss-queued-milestones.test.ts | 6 +- ...discuss-slice-structured-questions.test.ts | 2 +- .../sf/tests/dispatch-guard.test.ts | 4 +- .../tests/dispatch-missing-task-plans.test.ts | 12 +- .../tests/dispatch-uat-last-completed.test.ts | 6 +- .../sf/tests/doctor-providers.test.ts | 40 +- .../tests/doctor-scope-db-unavailable.test.ts | 10 +- .../sf/tests/draft-promotion.test.ts | 28 +- .../enhanced-verification-integration.test.ts | 2 +- .../sf/tests/ensure-db-open.test.ts | 28 +- .../sf/tests/export-html-all.test.ts | 14 +- .../sf/tests/export-html-enhancements.test.ts | 6 +- .../extension-bootstrap-isolation.test.ts | 14 +- .../sf/tests/file-change-validator.test.ts | 2 +- .../extensions/sf/tests/file-lock.test.ts | 8 +- .../sf/tests/files-loadfile-eisdir.test.ts | 2 +- .../extensions/sf/tests/flag-file-db.test.ts | 2 +- .../tests/forensics-context-persist.test.ts | 12 +- .../sf/tests/forensics-db-completion.test.ts | 8 +- .../sf/tests/forensics-dedup.test.ts | 18 +- .../sf/tests/forensics-issue-routing.test.ts | 2 +- .../sf/tests/forensics-journal.test.ts | 6 +- .../sf/tests/freeform-decisions.test.ts | 8 +- .../extensions/sf/tests/gate-dispatch.test.ts | 2 +- .../extensions/sf/tests/gate-storage.test.ts | 2 +- .../sf/tests/git-checkpoint.test.ts | 2 +- .../extensions/sf/tests/graph-context.test.ts | 8 +- .../tests/guided-flow-dynamic-routing.test.ts | 4 +- .../guided-flow-session-isolation.test.ts | 12 +- .../tests/guided-flow-state-rebuild.test.ts | 4 +- .../sf/tests/headless-query.test.ts | 4 +- .../extensions/sf/tests/health-widget.test.ts | 6 +- .../sf/tests/hook-key-parsing.test.ts | 8 +- .../sf/tests/infra-errors-cooldown.test.ts | 2 +- .../extensions/sf/tests/init-wizard.test.ts | 12 +- .../sf/tests/integration-edge.test.ts | 28 +- .../all-milestones-complete-merge.test.ts | 2 +- .../integration/atomic-task-closeout.test.ts | 8 +- .../tests/integration/auto-preflight.test.ts | 30 +- .../tests/integration/auto-recovery.test.ts | 22 +- .../doctor-completion-deferral.test.ts | 8 +- .../integration/doctor-delimiter-fix.test.ts | 20 +- .../integration/doctor-enhancements.test.ts | 44 +- .../doctor-environment-worktree.test.ts | 2 +- .../integration/doctor-environment.test.ts | 2 +- .../doctor-false-positives.test.ts | 28 +- .../tests/integration/doctor-fixlevel.test.ts | 30 +- .../sf/tests/integration/doctor-git.test.ts | 88 +- .../doctor-roadmap-summary-atomicity.test.ts | 14 +- .../tests/integration/doctor-runtime.test.ts | 42 +- .../sf/tests/integration/doctor.test.ts | 66 +- ...ature-branch-lifecycle-integration.test.ts | 2 +- .../sf/tests/integration/git-locale.test.ts | 2 +- .../tests/integration/git-self-heal.test.ts | 20 +- .../sf/tests/integration/git-service.test.ts | 30 +- .../gitignore-staging-2570.test.ts | 2 +- .../integration/gitignore-tracked-sf.test.ts | 18 +- .../sf/tests/integration/headless-command.ts | 14 +- .../tests/integration/idle-recovery.test.ts | 26 +- .../inherited-repo-home-dir.test.ts | 14 +- .../integration/integration-lifecycle.test.ts | 26 +- .../integration-mixed-milestones.test.ts | 4 +- .../integration/integration-proof.test.ts | 24 +- .../tests/integration/migrate-command.test.ts | 26 +- .../milestone-transition-worktree.test.ts | 2 +- .../tests/integration/parallel-merge.test.ts | 8 +- ...rallel-workers-multi-milestone-e2e.test.ts | 2 +- .../sf/tests/integration/paths.test.ts | 2 +- .../queue-completed-milestone-perf.test.ts | 28 +- .../integration/queue-reorder-e2e.test.ts | 6 +- .../quick-branch-lifecycle.test.ts | 20 +- .../sf/tests/integration/run-uat.test.ts | 2 +- .../state-machine-edge-cases.test.ts | 74 +- .../state-machine-live-validation.test.ts | 86 +- .../state-machine-runtime-failures.test.ts | 16 +- .../tests/integration/token-savings.test.ts | 6 +- .../sf/tests/integration/worktree-e2e.test.ts | 6 +- .../sf/tests/interrupted-session-auto.test.ts | 10 +- .../sf/tests/interrupted-session-ui.test.ts | 2 +- .../sf/tests/journal-query-tool.test.ts | 4 +- .../extensions/sf/tests/journal.test.ts | 6 +- .../sf/tests/json-persistence-atomic.test.ts | 2 +- .../extensions/sf/tests/key-manager.test.ts | 6 +- .../extensions/sf/tests/knowledge.test.ts | 86 +- .../sf/tests/markdown-renderer.test.ts | 38 +- .../sf/tests/marketplace-test-fixtures.ts | 8 +- .../sf/tests/mcp-project-config.test.ts | 6 +- .../extensions/sf/tests/md-importer.test.ts | 26 +- .../sf/tests/memory-leak-guards.test.ts | 4 +- .../extensions/sf/tests/metrics.test.ts | 12 +- .../tests/migrate-external-worktree.test.ts | 6 +- .../sf/tests/migrate-hierarchy.test.ts | 2 +- .../sf/tests/migrate-parser.test.ts | 2 +- .../sf/tests/migrate-transformer.test.ts | 46 +- .../tests/migrate-validator-parsers.test.ts | 2 +- .../tests/migrate-writer-integration.test.ts | 48 +- .../sf/tests/migrate-writer.test.ts | 24 +- .../sf/tests/milestone-status-tool.test.ts | 8 +- ...milestone-transition-state-rebuild.test.ts | 8 +- .../sf/tests/model-isolation.test.ts | 8 +- .../sf/tests/model-unittype-mapping.test.ts | 14 +- .../sf/tests/notification-store.test.ts | 4 +- .../sf/tests/notification-widget.test.ts | 2 +- .../sf/tests/notifications-handler.test.ts | 4 +- .../sf/tests/orphaned-worktree-audit.test.ts | 4 +- .../extensions/sf/tests/overrides.test.ts | 2 +- .../tests/parallel-budget-atomicity.test.ts | 2 +- .../sf/tests/parallel-commit-scope.test.ts | 2 +- .../sf/tests/parallel-crash-recovery.test.ts | 2 +- .../tests/parallel-eligibility-ghost.test.ts | 2 +- .../sf/tests/parallel-orchestration.test.ts | 4 +- ...rallel-orchestrator-zombie-cleanup.test.ts | 2 +- .../parallel-worker-lock-contention.test.ts | 32 +- .../tests/parallel-worker-monitoring.test.ts | 4 +- .../extensions/sf/tests/park-db-sync.test.ts | 2 +- .../sf/tests/park-edge-cases.test.ts | 2 +- .../sf/tests/park-milestone.test.ts | 4 +- .../extensions/sf/tests/parsers.test.ts | 4 +- .../extensions/sf/tests/phase-anchor.test.ts | 2 +- .../phases-merge-error-stops-auto.test.ts | 2 +- ...an-milestone-artifact-verification.test.ts | 2 +- .../plan-milestone-queue-context.test.ts | 2 +- .../sf/tests/plan-milestone.test.ts | 18 +- .../extensions/sf/tests/plan-slice.test.ts | 12 +- .../extensions/sf/tests/plan-task.test.ts | 12 +- .../sf/tests/planning-crossval.test.ts | 8 +- .../sf/tests/post-exec-retry-bypass.test.ts | 8 +- .../sf/tests/post-mutation-hook.test.ts | 2 +- .../sf/tests/post-unit-hooks.test.ts | 2 +- .../sf/tests/pre-exec-backtick-strip.test.ts | 2 +- .../tests/pre-execution-fail-closed.test.ts | 8 +- .../tests/pre-execution-pause-wiring.test.ts | 8 +- .../tests/preferences-worktree-sync.test.ts | 8 +- .../extensions/sf/tests/preferences.test.ts | 30 +- .../preflight-context-draft-filter.test.ts | 10 +- .../tests/project-relocation-recovery.test.ts | 28 +- .../projection-no-plan-overwrite.test.ts | 2 +- .../tests/prompt-budget-enforcement.test.ts | 2 +- .../sf/tests/prompt-contracts.test.ts | 4 +- .../extensions/sf/tests/prompt-db.test.ts | 8 +- .../sf/tests/prompt-step-ordering.test.ts | 12 +- .../tests/prompt-system-gate-coverage.test.ts | 2 +- .../sf/tests/queue-draft-detection.test.ts | 20 +- .../extensions/sf/tests/queue-order.test.ts | 2 +- .../sf/tests/quick-auto-guard.test.ts | 14 +- .../sf/tests/quick-turn-end-cleanup.test.ts | 2 +- .../sf/tests/reactive-executor.test.ts | 72 +- .../sf/tests/reassess-detection.test.ts | 6 +- .../sf/tests/reassess-handler.test.ts | 24 +- .../tests/reconciliation-edge-cases.test.ts | 6 +- .../sf/tests/recovery-attempts-reset.test.ts | 2 +- .../sf/tests/regex-hardening.test.ts | 12 +- .../register-hooks-depth-verification.test.ts | 2 +- .../sf/tests/register-shortcuts.test.ts | 2 +- .../remediation-completion-guard.test.ts | 4 +- .../sf/tests/remote-questions.test.ts | 4 +- .../extensions/sf/tests/remote-status.test.ts | 2 +- .../extensions/sf/tests/reopen-slice.test.ts | 16 +- .../extensions/sf/tests/reopen-task.test.ts | 18 +- .../sf/tests/replan-handler.test.ts | 20 +- .../extensions/sf/tests/replan-slice.test.ts | 10 +- .../sf/tests/repo-identity-worktree.test.ts | 16 +- .../extensions/sf/tests/requirements.test.ts | 12 +- .../extensions/sf/tests/resolve-ts-hooks.mjs | 2 +- .../tests/resource-loader-import-path.test.ts | 2 +- .../tests/restore-tools-after-discuss.test.ts | 2 +- .../tests/retry-diagnostic-reasoning.test.ts | 12 +- .../sf/tests/retry-state-reset.test.ts | 4 +- .../sf/tests/rewrite-count-persist.test.ts | 2 +- .../sf/tests/rogue-file-detection.test.ts | 20 +- .../sf/tests/routing-history.test.ts | 2 +- .../extensions/sf/tests/rule-registry.test.ts | 2 +- .../sf/tests/schema-v9-sequence.test.ts | 16 +- .../sf/tests/session-lock-multipath.test.ts | 32 +- .../sf/tests/session-lock-regression.test.ts | 24 +- .../tests/session-lock-transient-read.test.ts | 12 +- .../extensions/sf/tests/sf-db.test.ts | 6 +- .../extensions/sf/tests/sf-inspect.test.ts | 4 +- .../sf/tests/sf-no-project-error.test.ts | 32 +- .../extensions/sf/tests/sf-recover.test.ts | 8 +- .../extensions/sf/tests/sf-tools.test.ts | 22 +- .../tests/sfroot-worktree-detection.test.ts | 12 +- .../extensions/sf/tests/shared-wal.test.ts | 18 +- .../sf/tests/show-config-command.test.ts | 10 +- .../sf/tests/silent-catch-diagnostics.test.ts | 16 +- .../sf/tests/single-writer-invariant.test.ts | 18 +- .../sf/tests/skill-activation.test.ts | 2 +- .../sf/tests/slice-disk-reconcile.test.ts | 8 +- .../sf/tests/slice-parallel-conflict.test.ts | 2 +- .../tests/slice-parallel-orchestrator.test.ts | 18 +- .../sf/tests/smart-entry-complete.test.ts | 2 +- .../sf/tests/smart-entry-draft.test.ts | 8 +- .../sf/tests/sqlite-unavailable-gate.test.ts | 2 +- .../sf/tests/stale-lockfile-recovery.test.ts | 2 +- .../stale-milestone-id-reservation.test.ts | 10 +- .../sf/tests/stale-queued-milestone.test.ts | 2 +- .../sf/tests/stale-worktree-cwd.test.ts | 2 +- .../sf/tests/stalled-tool-recovery.test.ts | 2 +- .../sf/tests/start-auto-detached.test.ts | 4 +- .../sf/tests/stash-pop-sf-conflict.test.ts | 6 +- .../tests/stash-queued-context-files.test.ts | 6 +- .../sf/tests/state-corruption-2945.test.ts | 4 +- .../sf/tests/state-derivation-parity.test.ts | 2 +- .../state-machine-full-walkthrough.test.ts | 28 +- .../sf/tests/status-db-open.test.ts | 2 +- .../sf/tests/steer-worktree-path.test.ts | 4 +- .../sf/tests/stop-auto-remote.test.ts | 2 +- .../sf/tests/subagent-agent-discovery.test.ts | 2 +- .../sf/tests/subagent-model-dispatch.test.ts | 20 +- .../tests/symlink-extension-discovery.test.ts | 2 +- .../tests/symlink-numbered-variants.test.ts | 4 +- .../extensions/sf/tests/sync-lock.test.ts | 2 +- .../tests/sync-worktree-skip-current.test.ts | 2 +- .../extensions/sf/tests/test-utils.ts | 4 +- .../extensions/sf/tests/token-profile.test.ts | 10 +- .../uat-stuck-loop-orphaned-worktree.test.ts | 6 +- .../extensions/sf/tests/undo.test.ts | 26 +- .../sf/tests/unit-ownership.test.ts | 2 +- .../extensions/sf/tests/unit-runtime.test.ts | 4 +- ...uctured-continue-context-injection.test.ts | 4 +- .../sf/tests/uok-audit-unified.test.ts | 4 +- .../sf/tests/uok-gitops-turn-action.test.ts | 2 +- .../sf/tests/uok-gitops-wiring.test.ts | 8 +- .../sf/tests/uok-model-policy.test.ts | 2 +- .../sf/tests/uok-plan-v2-wiring.test.ts | 10 +- .../sf/tests/update-command.test.ts | 32 +- .../sf/tests/vacuous-truth-slices.test.ts | 4 +- .../sf/tests/vacuum-recovery.test.ts | 2 +- .../sf/tests/validate-directory.test.ts | 2 +- .../validate-milestone-stuck-guard.test.ts | 2 +- .../validate-milestone-write-order.test.ts | 10 +- .../sf/tests/validate-milestone.test.ts | 12 +- .../tests/verify-artifact-tightened.test.ts | 2 +- .../sf/tests/visualizer-data.test.ts | 8 +- .../sf/tests/visualizer-overlay.test.ts | 6 +- .../wave4-write-safety-regressions.test.ts | 8 +- .../wave5-consistency-regressions.test.ts | 6 +- .../sf/tests/workflow-events.test.ts | 2 +- .../sf/tests/workflow-logger-audit.test.ts | 4 +- .../sf/tests/workflow-logger.test.ts | 4 +- .../sf/tests/workflow-manifest.test.ts | 2 +- .../sf/tests/workflow-mcp-auto-prep.test.ts | 4 +- .../extensions/sf/tests/workflow-mcp.test.ts | 48 +- .../sf/tests/workflow-templates.test.ts | 2 +- .../sf/tests/workflow-tool-executors.test.ts | 4 +- .../sf/tests/workspace-index.test.ts | 12 +- .../sf/tests/worktree-bugfix.test.ts | 10 +- .../sf/tests/worktree-db-integration.test.ts | 16 +- .../worktree-db-respawn-truncation.test.ts | 32 +- .../sf/tests/worktree-db-same-file.test.ts | 12 +- .../extensions/sf/tests/worktree-db.test.ts | 46 +- .../sf/tests/worktree-integration.test.ts | 6 +- .../sf/tests/worktree-manager.test.ts | 14 +- .../tests/worktree-post-create-hook.test.ts | 2 +- .../tests/worktree-preferences-sync.test.ts | 2 +- .../sf/tests/worktree-resolver.test.ts | 8 +- .../sf/tests/worktree-symlink-removal.test.ts | 4 +- .../sf/tests/worktree-sync-milestones.test.ts | 46 +- .../worktree-sync-overwrite-loop.test.ts | 2 +- .../sf/tests/worktree-sync-tasks.test.ts | 2 +- .../extensions/sf/tests/worktree.test.ts | 16 +- .../extensions/sf/tests/write-gate.test.ts | 6 +- .../sf/tests/zombie-sf-state.test.ts | 8 +- .../extensions/sf/tools/complete-milestone.ts | 4 +- .../extensions/sf/tools/complete-slice.ts | 4 +- .../extensions/sf/tools/complete-task.ts | 4 +- .../extensions/sf/tools/validate-milestone.ts | 4 +- src/resources/extensions/sf/triage-ui.ts | 2 +- src/resources/extensions/sf/types.ts | 2 +- src/resources/extensions/sf/undo.ts | 10 +- src/resources/extensions/sf/uok/plan-v2.ts | 6 +- .../extensions/sf/visualizer-overlay.ts | 2 +- .../extensions/sf/visualizer-views.ts | 2 +- .../extensions/sf/workflow-logger.ts | 8 +- .../extensions/sf/workflow-mcp-auto-prep.ts | 2 +- src/resources/extensions/sf/workflow-mcp.ts | 20 +- .../extensions/sf/workflow-projections.ts | 6 +- .../extensions/sf/workflow-reconcile.ts | 8 +- .../extensions/sf/workflow-templates.ts | 6 +- .../sf/workflow-templates/bugfix.md | 2 +- .../sf/workflow-templates/dep-upgrade.md | 2 +- .../sf/workflow-templates/full-project.md | 14 +- .../sf/workflow-templates/refactor.md | 2 +- .../sf/workflow-templates/security-audit.md | 2 +- .../sf/workflow-templates/small-feature.md | 4 +- .../extensions/sf/workflow-templates/spike.md | 2 +- .../extensions/sf/workspace-index.ts | 14 +- .../extensions/sf/worktree-command.ts | 40 +- .../extensions/sf/worktree-manager.ts | 22 +- .../extensions/sf/worktree-resolver.ts | 7 +- .../extensions/sf/write-intercept.ts | 14 +- .../extensions/shared/gsd-phase-state.ts | 8 + .../extensions/shared/sf-phase-state.ts | 32 + src/resources/skills/create-skill/SKILL.md | 4 +- .../references/gsd-skill-ecosystem.md | 6 +- .../create-skill/workflows/audit-skill.md | 6 +- .../workflows/create-new-skill.md | 6 +- src/resources/skills/create-workflow/SKILL.md | 8 +- .../references/feature-patterns.md | 2 +- .../workflows/create-from-scratch.md | 10 +- .../workflows/create-from-template.md | 10 +- .../github-workflows/references/gh/SKILL.md | 10 +- src/rtk.ts | 10 +- src/tests/rtk-execution-seams.test.ts | 25 + src/tests/rtk-session-stats.test.ts | 10 +- src/web/settings-service.ts | 2 +- src/web/subprocess-runner.ts | 4 +- tests/fixtures/provider.ts | 4 +- tests/live-regression/run.ts | 24 +- tests/live/run.ts | 2 +- tests/repro-worktree-bug/repro.mjs | 34 +- tests/smoke/test-help.ts | 4 +- tests/smoke/test-init.ts | 10 +- tests/smoke/test-version.ts | 4 +- tsconfig.extensions.json | 14 +- 1629 files changed, 6424 insertions(+), 256890 deletions(-) create mode 100644 .gsd/CODEBASE.md create mode 100644 .gsd/audit/events.jsonl create mode 100644 .gsd/notifications.jsonl rename native/crates/engine/src/{gsd_parser.rs => forge_parser.rs} (100%) delete mode 100644 src/resources/extensions/gsd/activity-log.ts delete mode 100644 src/resources/extensions/gsd/atomic-write.ts delete mode 100644 src/resources/extensions/gsd/auto-artifact-paths.ts delete mode 100644 src/resources/extensions/gsd/auto-budget.ts delete mode 100644 src/resources/extensions/gsd/auto-dashboard.ts delete mode 100644 src/resources/extensions/gsd/auto-direct-dispatch.ts delete mode 100644 src/resources/extensions/gsd/auto-dispatch.ts delete mode 100644 src/resources/extensions/gsd/auto-loop.ts delete mode 100644 src/resources/extensions/gsd/auto-model-selection.ts delete mode 100644 src/resources/extensions/gsd/auto-post-unit.ts delete mode 100644 src/resources/extensions/gsd/auto-prompts.ts delete mode 100644 src/resources/extensions/gsd/auto-recovery.ts delete mode 100644 src/resources/extensions/gsd/auto-start.ts delete mode 100644 src/resources/extensions/gsd/auto-supervisor.ts delete mode 100644 src/resources/extensions/gsd/auto-timeout-recovery.ts delete mode 100644 src/resources/extensions/gsd/auto-timers.ts delete mode 100644 src/resources/extensions/gsd/auto-tool-tracking.ts delete mode 100644 src/resources/extensions/gsd/auto-unit-closeout.ts delete mode 100644 src/resources/extensions/gsd/auto-utils.ts delete mode 100644 src/resources/extensions/gsd/auto-verification.ts delete mode 100644 src/resources/extensions/gsd/auto-worktree.ts delete mode 100644 src/resources/extensions/gsd/auto.ts delete mode 100644 src/resources/extensions/gsd/auto/detect-stuck.ts delete mode 100644 src/resources/extensions/gsd/auto/finalize-timeout.ts delete mode 100644 src/resources/extensions/gsd/auto/infra-errors.ts delete mode 100644 src/resources/extensions/gsd/auto/loop-deps.ts delete mode 100644 src/resources/extensions/gsd/auto/loop.ts delete mode 100644 src/resources/extensions/gsd/auto/phases.ts delete mode 100644 src/resources/extensions/gsd/auto/resolve.ts delete mode 100644 src/resources/extensions/gsd/auto/run-unit.ts delete mode 100644 src/resources/extensions/gsd/auto/session.ts delete mode 100644 src/resources/extensions/gsd/auto/types.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/crash-log.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/db-tools.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/dynamic-tools.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/journal-tools.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/notify-interceptor.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/provider-error-resume.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/query-tools.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/register-extension.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/register-hooks.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/register-shortcuts.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/sanitize-complete-milestone.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/system-context.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts delete mode 100644 src/resources/extensions/gsd/bootstrap/write-gate.ts delete mode 100644 src/resources/extensions/gsd/branch-patterns.ts delete mode 100644 src/resources/extensions/gsd/cache.ts delete mode 100644 src/resources/extensions/gsd/captures.ts delete mode 100644 src/resources/extensions/gsd/changelog.ts delete mode 100644 src/resources/extensions/gsd/claude-import.ts delete mode 100644 src/resources/extensions/gsd/codebase-generator.ts delete mode 100644 src/resources/extensions/gsd/collision-diagnostics.ts delete mode 100644 src/resources/extensions/gsd/commands-add-tests.ts delete mode 100644 src/resources/extensions/gsd/commands-backlog.ts delete mode 100644 src/resources/extensions/gsd/commands-bootstrap.ts delete mode 100644 src/resources/extensions/gsd/commands-cmux.ts delete mode 100644 src/resources/extensions/gsd/commands-codebase.ts delete mode 100644 src/resources/extensions/gsd/commands-config.ts delete mode 100644 src/resources/extensions/gsd/commands-do.ts delete mode 100644 src/resources/extensions/gsd/commands-extensions.ts delete mode 100644 src/resources/extensions/gsd/commands-extract-learnings.ts delete mode 100644 src/resources/extensions/gsd/commands-handlers.ts delete mode 100644 src/resources/extensions/gsd/commands-inspect.ts delete mode 100644 src/resources/extensions/gsd/commands-logs.ts delete mode 100644 src/resources/extensions/gsd/commands-maintenance.ts delete mode 100644 src/resources/extensions/gsd/commands-mcp-status.ts delete mode 100644 src/resources/extensions/gsd/commands-pr-branch.ts delete mode 100644 src/resources/extensions/gsd/commands-prefs-wizard.ts delete mode 100644 src/resources/extensions/gsd/commands-rate.ts delete mode 100644 src/resources/extensions/gsd/commands-session-report.ts delete mode 100644 src/resources/extensions/gsd/commands-ship.ts delete mode 100644 src/resources/extensions/gsd/commands-workflow-templates.ts delete mode 100644 src/resources/extensions/gsd/commands.ts delete mode 100644 src/resources/extensions/gsd/commands/catalog.ts delete mode 100644 src/resources/extensions/gsd/commands/context.ts delete mode 100644 src/resources/extensions/gsd/commands/dispatcher.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/auto.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/core.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/notifications-handler.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/ops.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/parallel.ts delete mode 100644 src/resources/extensions/gsd/commands/handlers/workflow.ts delete mode 100644 src/resources/extensions/gsd/commands/index.ts delete mode 100644 src/resources/extensions/gsd/complexity-classifier.ts delete mode 100644 src/resources/extensions/gsd/config-overlay.ts delete mode 100644 src/resources/extensions/gsd/constants.ts delete mode 100644 src/resources/extensions/gsd/context-budget.ts delete mode 100644 src/resources/extensions/gsd/context-injector.ts delete mode 100644 src/resources/extensions/gsd/context-masker.ts delete mode 100644 src/resources/extensions/gsd/context-store.ts delete mode 100644 src/resources/extensions/gsd/crash-recovery.ts delete mode 100644 src/resources/extensions/gsd/custom-execution-policy.ts delete mode 100644 src/resources/extensions/gsd/custom-verification.ts delete mode 100644 src/resources/extensions/gsd/custom-workflow-engine.ts delete mode 100644 src/resources/extensions/gsd/dashboard-overlay.ts delete mode 100644 src/resources/extensions/gsd/db-writer.ts delete mode 100644 src/resources/extensions/gsd/debug-logger.ts delete mode 100644 src/resources/extensions/gsd/definition-io.ts delete mode 100644 src/resources/extensions/gsd/definition-loader.ts delete mode 100644 src/resources/extensions/gsd/detection.ts delete mode 100644 src/resources/extensions/gsd/dev-execution-policy.ts delete mode 100644 src/resources/extensions/gsd/dev-workflow-engine.ts delete mode 100644 src/resources/extensions/gsd/diff-context.ts delete mode 100644 src/resources/extensions/gsd/dispatch-guard.ts delete mode 100644 src/resources/extensions/gsd/docs/claude-marketplace-import.md delete mode 100644 src/resources/extensions/gsd/docs/preferences-reference.md delete mode 100644 src/resources/extensions/gsd/doctor-checks.ts delete mode 100644 src/resources/extensions/gsd/doctor-engine-checks.ts delete mode 100644 src/resources/extensions/gsd/doctor-environment.ts delete mode 100644 src/resources/extensions/gsd/doctor-format.ts delete mode 100644 src/resources/extensions/gsd/doctor-git-checks.ts delete mode 100644 src/resources/extensions/gsd/doctor-global-checks.ts delete mode 100644 src/resources/extensions/gsd/doctor-proactive.ts delete mode 100644 src/resources/extensions/gsd/doctor-providers.ts delete mode 100644 src/resources/extensions/gsd/doctor-runtime-checks.ts delete mode 100644 src/resources/extensions/gsd/doctor-types.ts delete mode 100644 src/resources/extensions/gsd/doctor.ts delete mode 100644 src/resources/extensions/gsd/engine-resolver.ts delete mode 100644 src/resources/extensions/gsd/engine-types.ts delete mode 100644 src/resources/extensions/gsd/env-utils.ts delete mode 100644 src/resources/extensions/gsd/error-classifier.ts delete mode 100644 src/resources/extensions/gsd/error-utils.ts delete mode 100644 src/resources/extensions/gsd/errors.ts delete mode 100644 src/resources/extensions/gsd/execution-policy.ts delete mode 100644 src/resources/extensions/gsd/exit-command.ts delete mode 100644 src/resources/extensions/gsd/export-html.ts delete mode 100644 src/resources/extensions/gsd/export.ts delete mode 100644 src/resources/extensions/gsd/extension-manifest.json delete mode 100644 src/resources/extensions/gsd/file-lock.ts delete mode 100644 src/resources/extensions/gsd/files.ts delete mode 100644 src/resources/extensions/gsd/forensics.ts delete mode 100644 src/resources/extensions/gsd/gate-registry.ts delete mode 100644 src/resources/extensions/gsd/git-constants.ts delete mode 100644 src/resources/extensions/gsd/git-self-heal.ts delete mode 100644 src/resources/extensions/gsd/git-service.ts delete mode 100644 src/resources/extensions/gsd/gitignore.ts delete mode 100644 src/resources/extensions/gsd/graph-context.ts delete mode 100644 src/resources/extensions/gsd/graph.ts delete mode 100644 src/resources/extensions/gsd/gsd-db.ts delete mode 100644 src/resources/extensions/gsd/guided-flow-queue.ts delete mode 100644 src/resources/extensions/gsd/guided-flow.ts delete mode 100644 src/resources/extensions/gsd/health-widget-core.ts delete mode 100644 src/resources/extensions/gsd/health-widget.ts delete mode 100644 src/resources/extensions/gsd/history.ts delete mode 100644 src/resources/extensions/gsd/index.ts delete mode 100644 src/resources/extensions/gsd/init-wizard.ts delete mode 100644 src/resources/extensions/gsd/interrupted-session.ts delete mode 100644 src/resources/extensions/gsd/journal.ts delete mode 100644 src/resources/extensions/gsd/json-persistence.ts delete mode 100644 src/resources/extensions/gsd/jsonl-utils.ts delete mode 100644 src/resources/extensions/gsd/key-manager.ts delete mode 100644 src/resources/extensions/gsd/learning/bayesian-blender.mjs delete mode 100644 src/resources/extensions/gsd/learning/bayesian-blender.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/data/model-benchmarks.json delete mode 100644 src/resources/extensions/gsd/learning/data/primary-provider-chain.json delete mode 100644 src/resources/extensions/gsd/learning/data/unit-weights.json delete mode 100644 src/resources/extensions/gsd/learning/fallback-chain-writer.mjs delete mode 100644 src/resources/extensions/gsd/learning/fallback-chain-writer.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/hook-handler.mjs delete mode 100644 src/resources/extensions/gsd/learning/hook-handler.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/index.mjs delete mode 100644 src/resources/extensions/gsd/learning/integration.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/loadCapabilityOverrides.mjs delete mode 100644 src/resources/extensions/gsd/learning/loadCapabilityOverrides.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/outcome-aggregator.mjs delete mode 100644 src/resources/extensions/gsd/learning/outcome-recorder.mjs delete mode 100644 src/resources/extensions/gsd/learning/outcome-recorder.test.mjs delete mode 100644 src/resources/extensions/gsd/learning/outcome-schema.sql delete mode 100644 src/resources/extensions/gsd/learning/runtime.ts delete mode 100644 src/resources/extensions/gsd/markdown-renderer.ts delete mode 100644 src/resources/extensions/gsd/marketplace-discovery.ts delete mode 100644 src/resources/extensions/gsd/mcp-project-config.ts delete mode 100644 src/resources/extensions/gsd/md-importer.ts delete mode 100644 src/resources/extensions/gsd/memory-extractor.ts delete mode 100644 src/resources/extensions/gsd/memory-store.ts delete mode 100644 src/resources/extensions/gsd/metrics.ts delete mode 100644 src/resources/extensions/gsd/migrate-external.ts delete mode 100644 src/resources/extensions/gsd/migrate/command.ts delete mode 100644 src/resources/extensions/gsd/migrate/index.ts delete mode 100644 src/resources/extensions/gsd/migrate/parser.ts delete mode 100644 src/resources/extensions/gsd/migrate/parsers.ts delete mode 100644 src/resources/extensions/gsd/migrate/preview.ts delete mode 100644 src/resources/extensions/gsd/migrate/transformer.ts delete mode 100644 src/resources/extensions/gsd/migrate/types.ts delete mode 100644 src/resources/extensions/gsd/migrate/validator.ts delete mode 100644 src/resources/extensions/gsd/migrate/writer.ts delete mode 100644 src/resources/extensions/gsd/milestone-actions.ts delete mode 100644 src/resources/extensions/gsd/milestone-id-utils.ts delete mode 100644 src/resources/extensions/gsd/milestone-ids.ts delete mode 100644 src/resources/extensions/gsd/milestone-validation-gates.ts delete mode 100644 src/resources/extensions/gsd/model-cost-table.ts delete mode 100644 src/resources/extensions/gsd/model-router.ts delete mode 100644 src/resources/extensions/gsd/namespaced-registry.ts delete mode 100644 src/resources/extensions/gsd/namespaced-resolver.ts delete mode 100644 src/resources/extensions/gsd/native-git-bridge.ts delete mode 100644 src/resources/extensions/gsd/native-parser-bridge.ts delete mode 100644 src/resources/extensions/gsd/notification-overlay.ts delete mode 100644 src/resources/extensions/gsd/notification-store.ts delete mode 100644 src/resources/extensions/gsd/notification-widget.ts delete mode 100644 src/resources/extensions/gsd/notifications.ts delete mode 100644 src/resources/extensions/gsd/observability-validator.ts delete mode 100644 src/resources/extensions/gsd/package.json delete mode 100644 src/resources/extensions/gsd/parallel-eligibility.ts delete mode 100644 src/resources/extensions/gsd/parallel-merge.ts delete mode 100644 src/resources/extensions/gsd/parallel-monitor-overlay.ts delete mode 100644 src/resources/extensions/gsd/parallel-orchestrator.ts delete mode 100644 src/resources/extensions/gsd/parsers-legacy.ts delete mode 100644 src/resources/extensions/gsd/paths.ts delete mode 100644 src/resources/extensions/gsd/phase-anchor.ts delete mode 100644 src/resources/extensions/gsd/plugin-importer.ts delete mode 100644 src/resources/extensions/gsd/post-execution-checks.ts delete mode 100644 src/resources/extensions/gsd/post-unit-hooks.ts delete mode 100644 src/resources/extensions/gsd/pre-execution-checks.ts delete mode 100644 src/resources/extensions/gsd/preferences-models.ts delete mode 100644 src/resources/extensions/gsd/preferences-skills.ts delete mode 100644 src/resources/extensions/gsd/preferences-types.ts delete mode 100644 src/resources/extensions/gsd/preferences-validation.ts delete mode 100644 src/resources/extensions/gsd/preferences.ts delete mode 100644 src/resources/extensions/gsd/preparation.ts delete mode 100644 src/resources/extensions/gsd/progress-score.ts delete mode 100644 src/resources/extensions/gsd/prompt-cache-optimizer.ts delete mode 100644 src/resources/extensions/gsd/prompt-loader.ts delete mode 100644 src/resources/extensions/gsd/prompt-ordering.ts delete mode 100644 src/resources/extensions/gsd/prompt-validation.ts delete mode 100644 src/resources/extensions/gsd/prompts/add-tests.md delete mode 100644 src/resources/extensions/gsd/prompts/complete-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/complete-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/discuss-headless.md delete mode 100644 src/resources/extensions/gsd/prompts/discuss.md delete mode 100644 src/resources/extensions/gsd/prompts/doctor-heal.md delete mode 100644 src/resources/extensions/gsd/prompts/execute-task.md delete mode 100644 src/resources/extensions/gsd/prompts/forensics.md delete mode 100644 src/resources/extensions/gsd/prompts/gate-evaluate.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-complete-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-discuss-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-discuss-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-execute-task.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-plan-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-plan-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-research-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/guided-resume-task.md delete mode 100644 src/resources/extensions/gsd/prompts/heal-skill.md delete mode 100644 src/resources/extensions/gsd/prompts/parallel-research-slices.md delete mode 100644 src/resources/extensions/gsd/prompts/plan-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/plan-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/queue.md delete mode 100644 src/resources/extensions/gsd/prompts/quick-task.md delete mode 100644 src/resources/extensions/gsd/prompts/reactive-execute.md delete mode 100644 src/resources/extensions/gsd/prompts/reassess-roadmap.md delete mode 100644 src/resources/extensions/gsd/prompts/replan-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/research-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/research-slice.md delete mode 100644 src/resources/extensions/gsd/prompts/rethink.md delete mode 100644 src/resources/extensions/gsd/prompts/review-migration.md delete mode 100644 src/resources/extensions/gsd/prompts/rewrite-docs.md delete mode 100644 src/resources/extensions/gsd/prompts/run-uat.md delete mode 100644 src/resources/extensions/gsd/prompts/system.md delete mode 100644 src/resources/extensions/gsd/prompts/triage-captures.md delete mode 100644 src/resources/extensions/gsd/prompts/validate-milestone.md delete mode 100644 src/resources/extensions/gsd/prompts/workflow-start.md delete mode 100644 src/resources/extensions/gsd/prompts/worktree-merge.md delete mode 100644 src/resources/extensions/gsd/provider-error-pause.ts delete mode 100644 src/resources/extensions/gsd/queue-order.ts delete mode 100644 src/resources/extensions/gsd/queue-reorder-ui.ts delete mode 100644 src/resources/extensions/gsd/quick.ts delete mode 100644 src/resources/extensions/gsd/reactive-graph.ts delete mode 100644 src/resources/extensions/gsd/repo-identity.ts delete mode 100644 src/resources/extensions/gsd/reports.ts delete mode 100644 src/resources/extensions/gsd/rethink.ts delete mode 100644 src/resources/extensions/gsd/roadmap-mutations.ts delete mode 100644 src/resources/extensions/gsd/roadmap-slices.ts delete mode 100644 src/resources/extensions/gsd/routing-history.ts delete mode 100644 src/resources/extensions/gsd/rule-registry.ts delete mode 100644 src/resources/extensions/gsd/rule-types.ts delete mode 100644 src/resources/extensions/gsd/run-manager.ts delete mode 100644 src/resources/extensions/gsd/safe-fs.ts delete mode 100644 src/resources/extensions/gsd/safety/content-validator.ts delete mode 100644 src/resources/extensions/gsd/safety/destructive-guard.ts delete mode 100644 src/resources/extensions/gsd/safety/evidence-collector.ts delete mode 100644 src/resources/extensions/gsd/safety/evidence-cross-ref.ts delete mode 100644 src/resources/extensions/gsd/safety/file-change-validator.ts delete mode 100644 src/resources/extensions/gsd/safety/git-checkpoint.ts delete mode 100644 src/resources/extensions/gsd/safety/safety-harness.ts delete mode 100644 src/resources/extensions/gsd/service-tier.ts delete mode 100644 src/resources/extensions/gsd/session-forensics.ts delete mode 100644 src/resources/extensions/gsd/session-lock.ts delete mode 100644 src/resources/extensions/gsd/session-model-override.ts delete mode 100644 src/resources/extensions/gsd/session-status-io.ts delete mode 100644 src/resources/extensions/gsd/shortcut-defs.ts delete mode 100644 src/resources/extensions/gsd/skill-catalog.ts delete mode 100644 src/resources/extensions/gsd/skill-discovery.ts delete mode 100644 src/resources/extensions/gsd/skill-health.ts delete mode 100644 src/resources/extensions/gsd/skill-telemetry.ts delete mode 100644 src/resources/extensions/gsd/skills/gsd-headless/SKILL.md delete mode 100644 src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md delete mode 100644 src/resources/extensions/gsd/skills/gsd-headless/references/commands.md delete mode 100644 src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md delete mode 100644 src/resources/extensions/gsd/slice-parallel-conflict.ts delete mode 100644 src/resources/extensions/gsd/slice-parallel-eligibility.ts delete mode 100644 src/resources/extensions/gsd/slice-parallel-orchestrator.ts delete mode 100644 src/resources/extensions/gsd/state.ts delete mode 100644 src/resources/extensions/gsd/status-guards.ts delete mode 100644 src/resources/extensions/gsd/structured-data-formatter.ts delete mode 100644 src/resources/extensions/gsd/sync-lock.ts delete mode 100644 src/resources/extensions/gsd/templates/PREFERENCES.md delete mode 100644 src/resources/extensions/gsd/templates/context.md delete mode 100644 src/resources/extensions/gsd/templates/decisions.md delete mode 100644 src/resources/extensions/gsd/templates/knowledge.md delete mode 100644 src/resources/extensions/gsd/templates/milestone-summary.md delete mode 100644 src/resources/extensions/gsd/templates/milestone-validation.md delete mode 100644 src/resources/extensions/gsd/templates/plan.md delete mode 100644 src/resources/extensions/gsd/templates/project.md delete mode 100644 src/resources/extensions/gsd/templates/reassessment.md delete mode 100644 src/resources/extensions/gsd/templates/requirements.md delete mode 100644 src/resources/extensions/gsd/templates/research.md delete mode 100644 src/resources/extensions/gsd/templates/roadmap.md delete mode 100644 src/resources/extensions/gsd/templates/runtime.md delete mode 100644 src/resources/extensions/gsd/templates/secrets-manifest.md delete mode 100644 src/resources/extensions/gsd/templates/slice-context.md delete mode 100644 src/resources/extensions/gsd/templates/slice-summary.md delete mode 100644 src/resources/extensions/gsd/templates/state.md delete mode 100644 src/resources/extensions/gsd/templates/task-plan.md delete mode 100644 src/resources/extensions/gsd/templates/task-summary.md delete mode 100644 src/resources/extensions/gsd/templates/uat.md delete mode 100644 src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/activity-log.test.ts delete mode 100644 src/resources/extensions/gsd/tests/agent-end-retry.test.ts delete mode 100644 src/resources/extensions/gsd/tests/artifact-corruption-2630.test.ts delete mode 100644 src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts delete mode 100644 src/resources/extensions/gsd/tests/atomic-write.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-dashboard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-lock-creation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-loop.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-milestone-target.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-mode-interactive-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-model-selection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-post-unit-step-message.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-pr-bugs.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-project-root-env.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-start-cold-db-bootstrap.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-start-time-persistence.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-start-worktree-db-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-supervisor.test.mjs delete mode 100644 src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts delete mode 100644 src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/autocomplete-regressions-1675.test.ts delete mode 100644 src/resources/extensions/gsd/tests/block-db-writes.test.ts delete mode 100644 src/resources/extensions/gsd/tests/bootstrap-derive-state-db-open.test.ts delete mode 100644 src/resources/extensions/gsd/tests/browser-teardown.test.ts delete mode 100644 src/resources/extensions/gsd/tests/budget-prediction.test.ts delete mode 100644 src/resources/extensions/gsd/tests/bundled-workflow-defs.test.ts delete mode 100644 src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts delete mode 100644 src/resources/extensions/gsd/tests/capability-router.test.ts delete mode 100644 src/resources/extensions/gsd/tests/captures.test.ts delete mode 100644 src/resources/extensions/gsd/tests/claude-import-marketplace-discovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/claude-import-tui.test.ts delete mode 100644 src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts delete mode 100644 src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts delete mode 100644 src/resources/extensions/gsd/tests/cli-provider-rate-limit.test.ts delete mode 100644 src/resources/extensions/gsd/tests/cmux.test.ts delete mode 100644 src/resources/extensions/gsd/tests/codebase-generator.test.ts delete mode 100644 src/resources/extensions/gsd/tests/cold-resume-db-reopen.test.ts delete mode 100644 src/resources/extensions/gsd/tests/collect-from-manifest.test.ts delete mode 100644 src/resources/extensions/gsd/tests/collision-diagnostics.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-backlog.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-config.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-do.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-inspect-open-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-logs.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-pr-branch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-session-report.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-ship.test.ts delete mode 100644 src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-milestone-false-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-milestone.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-slice-gate-closure.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-slice-prompt-task-summary-layout.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-slice-verification-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-slice.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-task-normalize-lists.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-task-rollback-evidence.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complete-task.test.ts delete mode 100644 src/resources/extensions/gsd/tests/completed-at-reconcile.test.ts delete mode 100644 src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts delete mode 100644 src/resources/extensions/gsd/tests/completion-hierarchy-guards.test.ts delete mode 100644 src/resources/extensions/gsd/tests/complexity-classifier.test.ts delete mode 100644 src/resources/extensions/gsd/tests/context-budget.test.ts delete mode 100644 src/resources/extensions/gsd/tests/context-injector.test.ts delete mode 100644 src/resources/extensions/gsd/tests/context-masker.test.ts delete mode 100644 src/resources/extensions/gsd/tests/context-store.test.ts delete mode 100644 src/resources/extensions/gsd/tests/copy-planning-artifacts-samepath.test.ts delete mode 100644 src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts delete mode 100644 src/resources/extensions/gsd/tests/cost-projection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts delete mode 100644 src/resources/extensions/gsd/tests/crash-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/custom-verification.test.ts delete mode 100644 src/resources/extensions/gsd/tests/custom-workflow-engine.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dashboard-budget.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dashboard-custom-engine.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dashboard-model-label-ordering.test.ts delete mode 100644 src/resources/extensions/gsd/tests/db-access-guardrails.test.ts delete mode 100644 src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts delete mode 100644 src/resources/extensions/gsd/tests/db-writer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/debug-logger.test.ts delete mode 100644 src/resources/extensions/gsd/tests/decision-scope-cascade.test.ts delete mode 100644 src/resources/extensions/gsd/tests/defer-milestone-stamp.test.ts delete mode 100644 src/resources/extensions/gsd/tests/deferred-slice-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/definition-io.test.ts delete mode 100644 src/resources/extensions/gsd/tests/definition-loader.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-crossval.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-db-disk-reconcile.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-deps.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-draft.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state-helpers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/derive-state.test.ts delete mode 100644 src/resources/extensions/gsd/tests/detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts delete mode 100644 src/resources/extensions/gsd/tests/diff-context.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discord-invite-links.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-empty-db-fallback.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-incremental-persistence.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-prompt.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-queued-milestones.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-slice-structured-questions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-tool-scope-leak.test.ts delete mode 100644 src/resources/extensions/gsd/tests/discuss-tool-scoping.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dispatch-guard-closed-status.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dispatch-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dispatch-uat-last-completed.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dispatcher-stuck-planning.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dist-redirect.mjs delete mode 100644 src/resources/extensions/gsd/tests/doctor-fix-flag.test.ts delete mode 100644 src/resources/extensions/gsd/tests/doctor-heal-fixable-warnings.test.ts delete mode 100644 src/resources/extensions/gsd/tests/doctor-providers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts delete mode 100644 src/resources/extensions/gsd/tests/double-merge-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/draft-promotion.test.ts delete mode 100644 src/resources/extensions/gsd/tests/dynamic-routing-default.test.ts delete mode 100644 src/resources/extensions/gsd/tests/empty-content-abort-loop.test.ts delete mode 100644 src/resources/extensions/gsd/tests/engine-interfaces-contract.test.ts delete mode 100644 src/resources/extensions/gsd/tests/enhanced-verification-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/ensure-db-open.test.ts delete mode 100644 src/resources/extensions/gsd/tests/error-success-mask.test.ts delete mode 100644 src/resources/extensions/gsd/tests/est-annotation-timeout.test.ts delete mode 100644 src/resources/extensions/gsd/tests/event-replay-idempotency.test.ts delete mode 100644 src/resources/extensions/gsd/tests/execute-task-prompt-existing-artifact-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/exit-command.test.ts delete mode 100644 src/resources/extensions/gsd/tests/export-html-all.test.ts delete mode 100644 src/resources/extensions/gsd/tests/export-html-enhancements.test.ts delete mode 100644 src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/extension-selector-separator.test.ts delete mode 100644 src/resources/extensions/gsd/tests/false-degraded-mode-warning.test.ts delete mode 100644 src/resources/extensions/gsd/tests/file-change-validator.test.ts delete mode 100644 src/resources/extensions/gsd/tests/file-lock.test.ts delete mode 100644 src/resources/extensions/gsd/tests/files-loadfile-eisdir.test.ts delete mode 100644 src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/find-missing-summaries-closed.test.ts delete mode 100644 src/resources/extensions/gsd/tests/flag-file-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-context-persist.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-db-completion.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-dedup.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-error-filter.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-issue-routing.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-journal.test.ts delete mode 100644 src/resources/extensions/gsd/tests/forensics-stuck-loops.test.ts delete mode 100644 src/resources/extensions/gsd/tests/format-shortcut.test.ts delete mode 100644 src/resources/extensions/gsd/tests/freeform-decisions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/frontmatter-parse-noise.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gate-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gate-registry.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gate-storage.test.ts delete mode 100644 src/resources/extensions/gsd/tests/git-checkpoint.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gitignore-bg-shell.test.ts delete mode 100644 src/resources/extensions/gsd/tests/graph-context.test.ts delete mode 100644 src/resources/extensions/gsd/tests/graph-operations.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsd-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsd-inspect.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsd-no-project-error.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsd-recover.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsd-tools.test.ts delete mode 100644 src/resources/extensions/gsd/tests/gsdroot-worktree-detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/guided-flow-dynamic-routing.test.ts delete mode 100644 src/resources/extensions/gsd/tests/guided-flow-session-isolation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts delete mode 100644 src/resources/extensions/gsd/tests/headless-answers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/headless-query.test.ts delete mode 100644 src/resources/extensions/gsd/tests/health-widget.test.ts delete mode 100644 src/resources/extensions/gsd/tests/hook-key-parsing.test.ts delete mode 100644 src/resources/extensions/gsd/tests/hook-model-resolution.test.ts delete mode 100644 src/resources/extensions/gsd/tests/idle-watchdog-stall-override.test.ts delete mode 100644 src/resources/extensions/gsd/tests/import-done-milestones.test.ts delete mode 100644 src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts delete mode 100644 src/resources/extensions/gsd/tests/infra-error.test.ts delete mode 100644 src/resources/extensions/gsd/tests/infra-errors-cooldown.test.ts delete mode 100644 src/resources/extensions/gsd/tests/init-wizard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/insert-slice-no-wipe.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration-edge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/all-milestones-complete-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/atomic-task-closeout.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-preflight.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-secrets-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-stash-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/continue-here.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-completion-deferral.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-delimiter-fix.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-enhancements.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-environment-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-environment.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-false-positives.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-fixlevel.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-git.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-roadmap-summary-atomicity.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/doctor.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/e2e-workflow-pipeline-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/feature-branch-lifecycle-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/git-locale.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/git-self-heal.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/git-service.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/gitignore-staging-2570.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/gitignore-tracked-gsd.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/headless-command.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/idle-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/inherited-repo-home-dir.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/integration-lifecycle.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/integration-mixed-milestones.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/integration-proof.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/merge-cwd-restore.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/migrate-command.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/milestone-transition-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/parallel-merge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/parallel-workers-multi-milestone-e2e.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/paths.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/plugin-importer-live.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/queue-completed-milestone-perf.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/queue-reorder-e2e.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/quick-branch-lifecycle.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/run-uat.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/token-savings.test.ts delete mode 100644 src/resources/extensions/gsd/tests/integration/worktree-e2e.test.ts delete mode 100644 src/resources/extensions/gsd/tests/interactive-routing-bypass.test.ts delete mode 100644 src/resources/extensions/gsd/tests/interactive-tool-idle-exemption.test.ts delete mode 100644 src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts delete mode 100644 src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts delete mode 100644 src/resources/extensions/gsd/tests/isolation-none-branch-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/iterate-engine-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/journal-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/journal-query-tool.test.ts delete mode 100644 src/resources/extensions/gsd/tests/journal.test.ts delete mode 100644 src/resources/extensions/gsd/tests/json-persistence-atomic.test.ts delete mode 100644 src/resources/extensions/gsd/tests/key-manager.test.ts delete mode 100644 src/resources/extensions/gsd/tests/knowledge.test.ts delete mode 100644 src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts delete mode 100644 src/resources/extensions/gsd/tests/manifest-status.test.ts delete mode 100644 src/resources/extensions/gsd/tests/markdown-renderer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/marketplace-test-fixtures.ts delete mode 100644 src/resources/extensions/gsd/tests/mcp-project-config.test.ts delete mode 100644 src/resources/extensions/gsd/tests/mcp-status.test.ts delete mode 100644 src/resources/extensions/gsd/tests/md-importer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/measurement.test.ts delete mode 100644 src/resources/extensions/gsd/tests/memory-extractor.test.ts delete mode 100644 src/resources/extensions/gsd/tests/memory-leak-guards.test.ts delete mode 100644 src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts delete mode 100644 src/resources/extensions/gsd/tests/memory-store.test.ts delete mode 100644 src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts delete mode 100644 src/resources/extensions/gsd/tests/metrics.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-hierarchy.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-parser.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-transformer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/migrate-writer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/milestone-report-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/milestone-status-authoritative.test.ts delete mode 100644 src/resources/extensions/gsd/tests/milestone-status-tool.test.ts delete mode 100644 src/resources/extensions/gsd/tests/milestone-transition-state-rebuild.test.ts delete mode 100644 src/resources/extensions/gsd/tests/model-cost-table.test.ts delete mode 100644 src/resources/extensions/gsd/tests/model-isolation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/model-router.test.ts delete mode 100644 src/resources/extensions/gsd/tests/model-unittype-mapping.test.ts delete mode 100644 src/resources/extensions/gsd/tests/must-have-parser.test.ts delete mode 100644 src/resources/extensions/gsd/tests/namespaced-registry.test.ts delete mode 100644 src/resources/extensions/gsd/tests/namespaced-resolver.test.ts delete mode 100644 src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts delete mode 100644 src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts delete mode 100644 src/resources/extensions/gsd/tests/needs-remediation-revalidation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/next-milestone-id.test.ts delete mode 100644 src/resources/extensions/gsd/tests/none-mode-gates.test.ts delete mode 100644 src/resources/extensions/gsd/tests/note-captures-executed.test.ts delete mode 100644 src/resources/extensions/gsd/tests/notification-overlay.test.ts delete mode 100644 src/resources/extensions/gsd/tests/notification-store.test.ts delete mode 100644 src/resources/extensions/gsd/tests/notification-widget.test.ts delete mode 100644 src/resources/extensions/gsd/tests/notifications-handler.test.ts delete mode 100644 src/resources/extensions/gsd/tests/notifications.test.ts delete mode 100644 src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts delete mode 100644 src/resources/extensions/gsd/tests/overrides.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-commit-scope.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-eligibility-ghost.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-orchestration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-worker-lock-contention.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts delete mode 100644 src/resources/extensions/gsd/tests/park-db-sync.test.ts delete mode 100644 src/resources/extensions/gsd/tests/park-edge-cases.test.ts delete mode 100644 src/resources/extensions/gsd/tests/park-milestone.test.ts delete mode 100644 src/resources/extensions/gsd/tests/parsers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/phantom-ghost-detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/phantom-milestone-default-queued.test.ts delete mode 100644 src/resources/extensions/gsd/tests/phase-anchor.test.ts delete mode 100644 src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-milestone-artifact-verification.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-milestone-queue-context.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-milestone-title.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-milestone.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-quality-validator.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-slice.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plan-task.test.ts delete mode 100644 src/resources/extensions/gsd/tests/planning-crossval.test.ts delete mode 100644 src/resources/extensions/gsd/tests/plugin-importer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/post-exec-retry-bypass.test.ts delete mode 100644 src/resources/extensions/gsd/tests/post-execution-checks.test.ts delete mode 100644 src/resources/extensions/gsd/tests/post-mutation-hook.test.ts delete mode 100644 src/resources/extensions/gsd/tests/post-unit-hooks.test.ts delete mode 100644 src/resources/extensions/gsd/tests/post-unit-state-rebuild.test.ts delete mode 100644 src/resources/extensions/gsd/tests/pre-exec-backtick-strip.test.ts delete mode 100644 src/resources/extensions/gsd/tests/pre-execution-checks.test.ts delete mode 100644 src/resources/extensions/gsd/tests/pre-execution-fail-closed.test.ts delete mode 100644 src/resources/extensions/gsd/tests/pre-execution-pause-wiring.test.ts delete mode 100644 src/resources/extensions/gsd/tests/preferences-formatting.test.ts delete mode 100644 src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts delete mode 100644 src/resources/extensions/gsd/tests/preferences.test.ts delete mode 100644 src/resources/extensions/gsd/tests/preflight-context-draft-filter.test.ts delete mode 100644 src/resources/extensions/gsd/tests/project-relocation-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/project-root-cwd-crash.test.ts delete mode 100644 src/resources/extensions/gsd/tests/projection-no-plan-overwrite.test.ts delete mode 100644 src/resources/extensions/gsd/tests/projection-regression.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-cache-optimizer.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-contracts.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-loader-replacement.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-loader-working-directory.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-ordering.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-system-gate-coverage.test.ts delete mode 100644 src/resources/extensions/gsd/tests/prompt-tool-names.test.ts delete mode 100644 src/resources/extensions/gsd/tests/provider-errors.test.ts delete mode 100644 src/resources/extensions/gsd/tests/quality-gates.test.ts delete mode 100644 src/resources/extensions/gsd/tests/query-tools-db-open.test.ts delete mode 100644 src/resources/extensions/gsd/tests/queue-draft-detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/queue-execution-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/queue-order.test.ts delete mode 100644 src/resources/extensions/gsd/tests/queued-discuss-fast-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/quick-auto-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/quick-turn-end-cleanup.test.ts delete mode 100644 src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reactive-executor.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reactive-graph.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reassess-detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reassess-handler.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reassess-prompt.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reconciliation-edge-cases.test.ts delete mode 100644 src/resources/extensions/gsd/tests/recovery-attempts-reset.test.ts delete mode 100644 src/resources/extensions/gsd/tests/regex-hardening.test.ts delete mode 100644 src/resources/extensions/gsd/tests/register-extension-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts delete mode 100644 src/resources/extensions/gsd/tests/register-shortcuts.test.ts delete mode 100644 src/resources/extensions/gsd/tests/remediation-completion-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/remote-questions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/remote-status.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reopen-slice.test.ts delete mode 100644 src/resources/extensions/gsd/tests/reopen-task.test.ts delete mode 100644 src/resources/extensions/gsd/tests/replan-handler.test.ts delete mode 100644 src/resources/extensions/gsd/tests/replan-slice.test.ts delete mode 100644 src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/requirements.test.ts delete mode 100644 src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs delete mode 100644 src/resources/extensions/gsd/tests/resolve-ts.mjs delete mode 100644 src/resources/extensions/gsd/tests/resource-loader-import-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/restore-tools-after-discuss.test.ts delete mode 100644 src/resources/extensions/gsd/tests/retry-diagnostic-reasoning.test.ts delete mode 100644 src/resources/extensions/gsd/tests/retry-state-reset.test.ts delete mode 100644 src/resources/extensions/gsd/tests/rewrite-count-persist.test.ts delete mode 100644 src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts delete mode 100644 src/resources/extensions/gsd/tests/roadmap-slices.test.ts delete mode 100644 src/resources/extensions/gsd/tests/rogue-file-detection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/routing-history.test.ts delete mode 100644 src/resources/extensions/gsd/tests/rule-registry.test.ts delete mode 100644 src/resources/extensions/gsd/tests/run-manager.test.ts delete mode 100644 src/resources/extensions/gsd/tests/run-uat-replay-cap.test.ts delete mode 100644 src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts delete mode 100644 src/resources/extensions/gsd/tests/secure-env-collect.test.ts delete mode 100644 src/resources/extensions/gsd/tests/service-tier.test.ts delete mode 100644 src/resources/extensions/gsd/tests/session-lock-multipath.test.ts delete mode 100644 src/resources/extensions/gsd/tests/session-lock-regression.test.ts delete mode 100644 src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts delete mode 100644 src/resources/extensions/gsd/tests/session-model-override.test.ts delete mode 100644 src/resources/extensions/gsd/tests/shared-wal.test.ts delete mode 100644 src/resources/extensions/gsd/tests/show-config-command.test.ts delete mode 100644 src/resources/extensions/gsd/tests/sidecar-queue.test.ts delete mode 100644 src/resources/extensions/gsd/tests/signal-handlers.test.ts delete mode 100644 src/resources/extensions/gsd/tests/silent-catch-diagnostics.test.ts delete mode 100644 src/resources/extensions/gsd/tests/single-writer-invariant.test.ts delete mode 100644 src/resources/extensions/gsd/tests/skill-activation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/skill-catalog.test.ts delete mode 100644 src/resources/extensions/gsd/tests/skill-lifecycle.test.ts delete mode 100644 src/resources/extensions/gsd/tests/skip-slice-state-rebuild.test.ts delete mode 100644 src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-context-injection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-parallel-conflict.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-parallel-eligibility.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-parallel-orchestrator.test.ts delete mode 100644 src/resources/extensions/gsd/tests/slice-sequence-insert.test.ts delete mode 100644 src/resources/extensions/gsd/tests/smart-entry-complete.test.ts delete mode 100644 src/resources/extensions/gsd/tests/smart-entry-draft.test.ts delete mode 100644 src/resources/extensions/gsd/tests/sqlite-unavailable-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stale-lockfile-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stale-milestone-id-reservation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stale-queued-milestone.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stale-slice-rows.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/start-auto-detached.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts delete mode 100644 src/resources/extensions/gsd/tests/state-corruption-2945.test.ts delete mode 100644 src/resources/extensions/gsd/tests/state-derivation-parity.test.ts delete mode 100644 src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts delete mode 100644 src/resources/extensions/gsd/tests/status-db-open.test.ts delete mode 100644 src/resources/extensions/gsd/tests/status-guards.test.ts delete mode 100644 src/resources/extensions/gsd/tests/steer-worktree-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stop-auto-merge-back.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stop-auto-race-null-unit.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stop-auto-remote.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stop-backtrack.test.ts delete mode 100644 src/resources/extensions/gsd/tests/structured-data-formatter.test.ts delete mode 100644 src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts delete mode 100644 src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/summary-render-parity.test.ts delete mode 100644 src/resources/extensions/gsd/tests/survivor-branch-complete.test.ts delete mode 100644 src/resources/extensions/gsd/tests/symlink-extension-discovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts delete mode 100644 src/resources/extensions/gsd/tests/sync-lock.test.ts delete mode 100644 src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts delete mode 100644 src/resources/extensions/gsd/tests/terminated-transient.test.ts delete mode 100644 src/resources/extensions/gsd/tests/test-helpers.ts delete mode 100644 src/resources/extensions/gsd/tests/test-utils.ts delete mode 100644 src/resources/extensions/gsd/tests/token-cost-display.test.ts delete mode 100644 src/resources/extensions/gsd/tests/token-counter.test.ts delete mode 100644 src/resources/extensions/gsd/tests/token-profile.test.ts delete mode 100644 src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/tool-compatibility.test.ts delete mode 100644 src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts delete mode 100644 src/resources/extensions/gsd/tests/tool-naming.test.ts delete mode 100644 src/resources/extensions/gsd/tests/tool-param-optionality.test.ts delete mode 100644 src/resources/extensions/gsd/tests/triage-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/triage-resolution.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uat-stuck-loop-orphaned-worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/unborn-branch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/undo.test.ts delete mode 100644 src/resources/extensions/gsd/tests/unique-milestone-ids.test.ts delete mode 100644 src/resources/extensions/gsd/tests/unit-ownership.test.ts delete mode 100644 src/resources/extensions/gsd/tests/unit-runtime.test.ts delete mode 100644 src/resources/extensions/gsd/tests/unstructured-continue-context-injection.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-audit-unified.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-contracts.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-execution-graph.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-flags.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-gate-runner.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-model-policy.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts delete mode 100644 src/resources/extensions/gsd/tests/uok-preferences.test.ts delete mode 100644 src/resources/extensions/gsd/tests/update-command.test.ts delete mode 100644 src/resources/extensions/gsd/tests/vacuous-truth-slices.test.ts delete mode 100644 src/resources/extensions/gsd/tests/vacuum-recovery.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validate-directory.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validate-milestone-prompt-verification-classes.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validate-milestone-stuck-guard.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validate-milestone.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts delete mode 100644 src/resources/extensions/gsd/tests/validation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/verdict-parser.test.ts delete mode 100644 src/resources/extensions/gsd/tests/verification-evidence.test.ts delete mode 100644 src/resources/extensions/gsd/tests/verification-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/verification-operational-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts delete mode 100644 src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts delete mode 100644 src/resources/extensions/gsd/tests/visualizer-data.test.ts delete mode 100644 src/resources/extensions/gsd/tests/visualizer-overlay.test.ts delete mode 100644 src/resources/extensions/gsd/tests/visualizer-views.test.ts delete mode 100644 src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/wave2-events-regressions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/wave3-session-regressions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/wave4-write-safety-regressions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts delete mode 100644 src/resources/extensions/gsd/tests/windows-path-normalization.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worker-model-override.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worker-registry.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-events.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-logger-wiring.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-logger.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-manifest.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-mcp.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-projections.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-reconcile.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-templates.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts delete mode 100644 src/resources/extensions/gsd/tests/workspace-index.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-bugfix.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-expected-warnings.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-health-monorepo.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-health.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-journal-events.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-main-branch.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-manager.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-preferences-sync.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-resolver.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-submodule-safety.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-sync-overwrite-loop.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-teardown-safety.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree.test.ts delete mode 100644 src/resources/extensions/gsd/tests/write-gate.test.ts delete mode 100644 src/resources/extensions/gsd/tests/write-intercept.test.ts delete mode 100644 src/resources/extensions/gsd/tests/zero-slice-roadmap-guided.test.ts delete mode 100644 src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts delete mode 100644 src/resources/extensions/gsd/token-counter.ts delete mode 100644 src/resources/extensions/gsd/tools/complete-milestone.ts delete mode 100644 src/resources/extensions/gsd/tools/complete-slice.ts delete mode 100644 src/resources/extensions/gsd/tools/complete-task.ts delete mode 100644 src/resources/extensions/gsd/tools/plan-milestone.ts delete mode 100644 src/resources/extensions/gsd/tools/plan-slice.ts delete mode 100644 src/resources/extensions/gsd/tools/plan-task.ts delete mode 100644 src/resources/extensions/gsd/tools/reassess-roadmap.ts delete mode 100644 src/resources/extensions/gsd/tools/reopen-milestone.ts delete mode 100644 src/resources/extensions/gsd/tools/reopen-slice.ts delete mode 100644 src/resources/extensions/gsd/tools/reopen-task.ts delete mode 100644 src/resources/extensions/gsd/tools/replan-slice.ts delete mode 100644 src/resources/extensions/gsd/tools/validate-milestone.ts delete mode 100644 src/resources/extensions/gsd/tools/workflow-tool-executors.ts delete mode 100644 src/resources/extensions/gsd/triage-resolution.ts delete mode 100644 src/resources/extensions/gsd/triage-ui.ts delete mode 100644 src/resources/extensions/gsd/types.ts delete mode 100644 src/resources/extensions/gsd/undo.ts delete mode 100644 src/resources/extensions/gsd/unit-id.ts delete mode 100644 src/resources/extensions/gsd/unit-ownership.ts delete mode 100644 src/resources/extensions/gsd/unit-runtime.ts delete mode 100644 src/resources/extensions/gsd/uok/audit-toggle.ts delete mode 100644 src/resources/extensions/gsd/uok/audit.ts delete mode 100644 src/resources/extensions/gsd/uok/contracts.ts delete mode 100644 src/resources/extensions/gsd/uok/execution-graph.ts delete mode 100644 src/resources/extensions/gsd/uok/flags.ts delete mode 100644 src/resources/extensions/gsd/uok/gate-runner.ts delete mode 100644 src/resources/extensions/gsd/uok/gitops.ts delete mode 100644 src/resources/extensions/gsd/uok/kernel.ts delete mode 100644 src/resources/extensions/gsd/uok/loop-adapter.ts delete mode 100644 src/resources/extensions/gsd/uok/model-policy.ts delete mode 100644 src/resources/extensions/gsd/uok/plan-v2.ts delete mode 100644 src/resources/extensions/gsd/validate-directory.ts delete mode 100644 src/resources/extensions/gsd/validation.ts delete mode 100644 src/resources/extensions/gsd/verdict-parser.ts delete mode 100644 src/resources/extensions/gsd/verification-evidence.ts delete mode 100644 src/resources/extensions/gsd/verification-gate.ts delete mode 100644 src/resources/extensions/gsd/visualizer-data.ts delete mode 100644 src/resources/extensions/gsd/visualizer-overlay.ts delete mode 100644 src/resources/extensions/gsd/visualizer-views.ts delete mode 100644 src/resources/extensions/gsd/watch/header-renderer.ts delete mode 100644 src/resources/extensions/gsd/workflow-engine.ts delete mode 100644 src/resources/extensions/gsd/workflow-events.ts delete mode 100644 src/resources/extensions/gsd/workflow-logger.ts delete mode 100644 src/resources/extensions/gsd/workflow-manifest.ts delete mode 100644 src/resources/extensions/gsd/workflow-mcp-auto-prep.ts delete mode 100644 src/resources/extensions/gsd/workflow-mcp.ts delete mode 100644 src/resources/extensions/gsd/workflow-migration.ts delete mode 100644 src/resources/extensions/gsd/workflow-projections.ts delete mode 100644 src/resources/extensions/gsd/workflow-reconcile.ts delete mode 100644 src/resources/extensions/gsd/workflow-templates.ts delete mode 100644 src/resources/extensions/gsd/workflow-templates/bugfix.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/dep-upgrade.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/full-project.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/hotfix.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/refactor.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/registry.json delete mode 100644 src/resources/extensions/gsd/workflow-templates/security-audit.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/small-feature.md delete mode 100644 src/resources/extensions/gsd/workflow-templates/spike.md delete mode 100644 src/resources/extensions/gsd/workspace-index.ts delete mode 100644 src/resources/extensions/gsd/worktree-command-bootstrap.ts delete mode 100644 src/resources/extensions/gsd/worktree-command.ts delete mode 100644 src/resources/extensions/gsd/worktree-health.ts delete mode 100644 src/resources/extensions/gsd/worktree-manager.ts delete mode 100644 src/resources/extensions/gsd/worktree-resolver.ts delete mode 100644 src/resources/extensions/gsd/worktree.ts delete mode 100644 src/resources/extensions/gsd/write-intercept.ts create mode 100644 src/resources/extensions/shared/gsd-phase-state.ts create mode 100644 src/resources/extensions/shared/sf-phase-state.ts diff --git a/.gitignore b/.gitignore index 0c2e04671..01b33ad11 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ dist/ !/pkg/dist/modes/ !/pkg/dist/core/export-html/ .bg_shell -.gsd*.tgz +.sf*.tgz .artifacts/ AGENTS.md .bg-shell/ @@ -71,14 +71,14 @@ docs/coherence-audit/ .plans/ # ── SF project state (per-worktree, never committed) ── -.gsd/ +.sf/ # ── Stale lock files (npm is canonical) ── pnpm-lock.yaml bun.lock # ── SF baseline (auto-generated) ── -.gsd +.sf # ── SF baseline (auto-generated) ── -.gsd-id +.sf-id diff --git a/.gsd/CODEBASE.md b/.gsd/CODEBASE.md new file mode 100644 index 000000000..adf65a520 --- /dev/null +++ b/.gsd/CODEBASE.md @@ -0,0 +1,482 @@ +# Codebase Map + +Generated: 2026-04-15T12:09:27Z | Files: 500 | Described: 0/500 + +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` + +### gsd-orchestrator/ +- `gsd-orchestrator/SKILL.md` + +### gsd-orchestrator/references/ +- `gsd-orchestrator/references/answer-injection.md` +- `gsd-orchestrator/references/commands.md` +- `gsd-orchestrator/references/json-result.md` + +### gsd-orchestrator/templates/ +- `gsd-orchestrator/templates/spec.md` + +### gsd-orchestrator/workflows/ +- `gsd-orchestrator/workflows/build-from-spec.md` +- `gsd-orchestrator/workflows/monitor-and-poll.md` +- `gsd-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` diff --git a/.gsd/audit/events.jsonl b/.gsd/audit/events.jsonl new file mode 100644 index 000000000..96de9d5c1 --- /dev/null +++ b/.gsd/audit/events.jsonl @@ -0,0 +1,2 @@ +{"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}} diff --git a/.gsd/notifications.jsonl b/.gsd/notifications.jsonl new file mode 100644 index 000000000..788a40e93 --- /dev/null +++ b/.gsd/notifications.jsonl @@ -0,0 +1,10 @@ +{"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} diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f32c8c8..14da9988a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.74.0] - 2026-04-14 ### Added -- **gsd**: extend flat-rate provider detection to custom/externalCli providers +- **sf**: extend flat-rate provider detection to custom/externalCli providers - **claude-code**: pass thinking level as effort ### Fixed @@ -18,18 +18,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **state**: DB-authoritative milestone completeness (#4179) - **auto-mode**: prevent false milestone merge after complete-milestone failure (#4175) - **auto**: pause on validate-milestone needs-remediation without slices (#4094) -- **gsd**: notify users what to do next after /gsd step finishes +- **sf**: notify users what to do next after /sf step finishes - **cli**: restore --help handling when it follows a subcommand or unknown flag - **tui**: eliminate pinned output duplication and reduce render overhead - **auto**: prevent premature auto-mode stops on blocked phase + missing reassessment - **cli**: use junction symlinks in merged node_modules path - **tui**: reset segment state on claude-code sub-turn shrink -- **gsd**: set completed_at when reconciling task status to complete +- **sf**: set completed_at when reconciling task status to complete - **tui**: keep AUTO-mode widgets alive and drop duplicate health panel -- **gsd**: use bun for update when installed via Bun (#4145) +- **sf**: use bun for update when installed via Bun (#4145) - **tui**: render assistant tool calls inline with text instead of grouped at end -- **gsd**: restore isAutoMode plumbing and workflow-logger catch in auto-model-selection -- **gsd**: preserve custom-model selection on /gsd auto bootstrap (#4122) +- **sf**: restore isAutoMode plumbing and workflow-logger catch in auto-model-selection +- **sf**: preserve custom-model selection on /sf auto bootstrap (#4122) - **pi-coding-agent**: use safe compaction role markers - **pi-ai**: detect claude-code overflow text @@ -40,52 +40,52 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.73.1] - 2026-04-13 ### Fixed -- **gsd**: address 3 silent-crash secondary issues from #3348 post-#3696 (#4133) -- **gsd**: tolerate corrupt task arrays (#4056) -- **gsd**: discard milestone DB and worktree state (#4065) +- **sf**: address 3 silent-crash secondary issues from #3348 post-#3696 (#4133) +- **sf**: tolerate corrupt task arrays (#4056) +- **sf**: discard milestone DB and worktree state (#4065) - **model-resolver**: gate saved default restore on provider readiness - **tui**: stop pinned latest-output mirror from duplicating streaming text -- **gsd**: wire subagent_model preference through to dispatch prompt builders +- **sf**: wire subagent_model preference through to dispatch prompt builders - **ci**: address 5 pipeline integrity issues from release audit (#4119) - **ci**: regenerate package-lock.json during version bump (#4116) - **pi-coding-agent**: skip localhost dummy key when fallback resolver provides a configured key ### Changed -- **gsd**: delete 3 unreferenced dead files and orphaned test (#3728) +- **sf**: delete 3 unreferenced dead files and orphaned test (#3728) ## [2.73.0] - 2026-04-13 ### Added - **pi-ai**: add Alibaba DashScope as standalone provider (#3891) -- **gsd**: add layered depth enforcement to discuss.md (#4079) +- **sf**: add layered depth enforcement to discuss.md (#4079) ### Fixed -- **gsd**: reconcile stale slice rows and rebuild STATE.md before DB close (#3658) -- **gsd**: block direct writes to gsd.db via hooks to prevent corruption (#3674) -- **gsd**: break 3 circular dependencies in extension modules (#3730) +- **sf**: reconcile stale slice rows and rebuild STATE.md before DB close (#3658) +- **sf**: block direct writes to sf.db via hooks to prevent corruption (#3674) +- **sf**: break 3 circular dependencies in extension modules (#3730) - **claude-code**: default SF subagents to bypassPermissions and pre-authorize safe built-ins (#4099 follow-up) -- **gsd**: add memory pressure watchdog and persist stuck detection state (#3708) +- **sf**: add memory pressure watchdog and persist stuck detection state (#3708) - **state**: prevent false degraded-mode warning when DB not yet initialized (#3922) - **async-jobs**: suppress stale follow-up for jobs consumed by await_job (#3787) (#3788) -- **gsd**: rebuild STATE.md after unit completion (#3876) -- **gsd**: let doctor heal dispatch fixable warnings (#3875) -- **gsd**: preserve experimental preferences in merges (#3847) -- **gsd**: heal legacy task arrays and evidence rows (#4027) -- **gsd**: unlock depth verification outside guided flow (#4058) -- **gsd**: preserve paused auto badge after provider pause (#4062) +- **sf**: rebuild STATE.md after unit completion (#3876) +- **sf**: let doctor heal dispatch fixable warnings (#3875) +- **sf**: preserve experimental preferences in merges (#3847) +- **sf**: heal legacy task arrays and evidence rows (#4027) +- **sf**: unlock depth verification outside guided flow (#4058) +- **sf**: preserve paused auto badge after provider pause (#4062) - **ollama**: add cloud auth support and resolve real context window via /api/show (#4017) - **security**: activate auth middleware and harden shutdown/update routes (#4023) -- **gsd**: normalize workingDirectory prompt paths (#4057) +- **sf**: normalize workingDirectory prompt paths (#4057) - **claude-code**: pre-authorize workflow MCP tools so interactive acceptEdits mode stops blocking SF commands - **cli**: resolve duplicate validateConfiguredModel and missing getPiDefaultModelAndProvider import - update SF runtime ignore patterns for team mode (#2824) -- **gsd**: prevent double frontmatter in task SUMMARY.md from projection re-render (#2818) +- **sf**: prevent double frontmatter in task SUMMARY.md from projection re-render (#2818) - flush extension provider registrations before model resolution (#1923) -- **gsd**: reset db-open attempted flag on close (#4024) -- **gsd**: unblock mixed-dependency zero-dep slices (#4025) +- **sf**: reset db-open attempted flag on close (#4024) +- **sf**: unblock mixed-dependency zero-dep slices (#4025) - **pi-tui**: filter kitty keypad private-use input (#4026) -- **gsd**: disable db mmap on darwin (#4029) -- **gsd**: reject empty roadmap stubs as milestone plans (#4063) +- **sf**: disable db mmap on darwin (#4029) +- **sf**: reject empty roadmap stubs as milestone plans (#4063) - persist defaultProvider when user selects Claude Code CLI in onboarding (#4104) - **pi-ai**: filter unavailable github copilot models (#4031) - **claude-code**: wrap prompt history in XML tags to stop transcript fabrication @@ -100,13 +100,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **agents**: add SF phase guard to prevent subagent/phase conflicts - **agents**: add 8 specialist subagents and slim pro agents -- **tui**: improve gsd overlays, shortcuts, and notification flows +- **tui**: improve sf overlays, shortcuts, and notification flows ### Fixed - **ci**: build artifacts in integration-tests job - **auto**: recover from OpenRouter credit affordability errors -- **gsd**: cast unknown gate id in test to satisfy GateId type -- **gsd**: route quality gates through a per-turn registry +- **sf**: cast unknown gate id in test to satisfy GateId type +- **sf**: route quality gates through a per-turn registry - **mcp**: expose every registered tool and fix SDK subpath resolution - **mcp**: resolve rebase regressions in stream-adapter - **mcp**: thread abort signals, restore tool fidelity, and fix subpath imports @@ -114,10 +114,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **tui**: overlay subscription + Ctrl+Shift+P shortcut conflict - **models**: block unconfigured models from selection surfaces - **ollama**: clear footer status when provider unavailable -- **gsd**: guard model override in minimal command contexts +- **sf**: guard model override in minimal command contexts - **model**: require provider readiness for saved default selection -- **gsd**: honor /gsd model as session override across dispatch -- **gsd**: use milestone branch for merged worktree cleanup +- **sf**: honor /sf model as session override across dispatch +- **sf**: use milestone branch for merged worktree cleanup - **pi-coding-agent**: show full OAuth login URLs - **auto**: add structured cooldown error and bounded retry budget - **auto**: survive transient 429 credential cooldown in auto sessions @@ -125,33 +125,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **headless**: keep idle timeout off during interactive tools - **claude-code-cli**: surface result text for success errors - **pi-ai**: use bearer auth for MiniMax Anthropic API -- **gsd**: scope stuck-loop forensics to auto sessions -- **gsd**: repair DB-only milestone unpark state -- **gsd**: detach auto start from active turns +- **sf**: scope stuck-loop forensics to auto sessions +- **sf**: repair DB-only milestone unpark state +- **sf**: detach auto start from active turns - **cli**: include all internal node_modules entries in pnpm merged dir -- **gsd**: enforce anti-fabrication turn-taking in discuss prompts +- **sf**: enforce anti-fabrication turn-taking in discuss prompts - **cli**: address review findings for pnpm merged node_modules - **cli**: handle pnpm global installs by merging both node_modules roots -- **gsd**: keep project db path after worktree enter -- **gsd**: ignore prose inputs in pre-exec checks -- **gsd**: read existing artifacts before write +- **sf**: keep project db path after worktree enter +- **sf**: ignore prose inputs in pre-exec checks +- **sf**: read existing artifacts before write - **mcp-server**: use explicit sdk js subpaths - **cli**: preserve anthropic api provider -- **gsd**: document flat task summary layout -- **gsd**: require verification classes in validation prompts +- **sf**: document flat task summary layout +- **sf**: require verification classes in validation prompts - **mcp-server**: open the DB for inline workflow tools -- **gsd**: ignore pre-existing files in task ordering -- **gsd**: detect property-value JSON invocation errors +- **sf**: ignore pre-existing files in task ordering +- **sf**: detect property-value JSON invocation errors - **cli**: honor custom-provider defaults before onboarding -- **gsd**: dedupe repeated notifications -- **gsd**: open DB before bootstrap deriveState +- **sf**: dedupe repeated notifications +- **sf**: open DB before bootstrap deriveState - **cli**: clean up stdin after sessions command readline interface closes -- **gsd**: skip reverse dependents in dispatch fallback -- **gsd**: classify plain connection-error as transient +- **sf**: skip reverse dependents in dispatch fallback +- **sf**: classify plain connection-error as transient - **cli**: resolve hoisted node_modules for global installs - **pi-ai**: cast test tool fixtures to any for TSchema compatibility - **commands**: use specific validation reason in blocked-directory warning -- **commands**: show friendly message when /gsd runs from $HOME instead of unhandled error +- **commands**: show friendly message when /sf runs from $HOME instead of unhandled error ### Changed - **ci**: run integration tests in parallel with build @@ -174,11 +174,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **claude-code**: accept secure_env_collect MCP elicitation forms - **interactive**: keep MCP tool output ordered and restore secure prompt fallback - **interactive**: preserve MCP tool output stream ordering -- **gsd**: resolve workflow MCP test typing regressions +- **sf**: resolve workflow MCP test typing regressions - **mcp**: return isError flag on workflow tool execution failures - **discuss**: add structuredQuestionsAvailable conditional to all gates - **discuss**: add multi-round questioning to new-project discuss phase -- **gsd**: harden claude-code workflow MCP bootstrap +- **sf**: harden claude-code workflow MCP bootstrap - **web**: drop provisional pre-tool question text ### Changed @@ -198,9 +198,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **auto**: use pathToFileURL for cross-platform import and reconcile regression test - **auto**: resolve resource-loader.js from SF_PKG_ROOT on resume (#3949) - **mcp-server**: importLocalModule resolves src/ paths from dist/ context -- **gsd**: surface scoped doctor health warnings -- **gsd**: skip skipped slices in milestone prompts -- **gsd**: handle doubled-backtick pre-exec paths +- **sf**: surface scoped doctor health warnings +- **sf**: skip skipped slices in milestone prompts +- **sf**: handle doubled-backtick pre-exec paths - **update**: fetch latest version from registry ## [2.70.0] - 2026-04-10 @@ -212,28 +212,28 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **pi-ai**: remove Anthropic OAuth flow for TOS compliance - **mcp-server**: hydrate model credentials into env - **mcp-server**: hydrate stored tool credentials on startup -- **gsd**: auto-enable cmux when detected instead of prompting +- **sf**: auto-enable cmux when detected instead of prompting - **mcp-server**: URL scheme regex no longer matches Windows drive letters ## [2.69.0] - 2026-04-10 ### Added -- **gsd**: implement ADR-005 multi-model provider and tool strategy -- **gsd**: complete ADR-004 capability-aware model routing implementation +- **sf**: implement ADR-005 multi-model provider and tool strategy +- **sf**: complete ADR-004 capability-aware model routing implementation ### Fixed -- **gsd**: add missing directories to codebase generator exclude list -- **gsd**: wire ADR-005 infrastructure into live paths -- **gsd**: replace empty catch with logWarning for CI compliance -- **gsd**: merge enhanced context sections into standard template, clean up stale gate patterns -- **gsd**: remove broken discuss-prepared template, inject briefs into discuss.md +- **sf**: add missing directories to codebase generator exclude list +- **sf**: wire ADR-005 infrastructure into live paths +- **sf**: replace empty catch with logWarning for CI compliance +- **sf**: merge enhanced context sections into standard template, clean up stale gate patterns +- **sf**: remove broken discuss-prepared template, inject briefs into discuss.md ## [2.68.1] - 2026-04-10 ### Fixed - **ci**: update FILE-SYSTEM-MAP.md path after docs reorganization - **test**: update discord invite test path after docs reorganization -- **gsd**: resolve resource-loader import for deployed extensions +- **sf**: resolve resource-loader import for deployed extensions ## [2.68.0] - 2026-04-10 @@ -249,46 +249,46 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **state**: prevent false degraded-mode warning when DB not yet initialized -- **gsd**: use debugLog in catch block to satisfy empty-catch lint -- **gsd**: avoid false manifest and skipped-slice warnings -- **gsd**: replace empty catch block with descriptive comment +- **sf**: use debugLog in catch block to satisfy empty-catch lint +- **sf**: avoid false manifest and skipped-slice warnings +- **sf**: replace empty catch block with descriptive comment - guard autoCommitDirtyState and restore cwd on MergeConflictError (#2929) - Claude Code MCP tool output rendering and real-time streaming -- **gsd**: surface warnings when DB or STATE.md init fails -- **gsd**: create gsd.db, runtime/, and STATE.md during init (#3880) -- **gsd**: suppress workflow stderr during /gsd -- **gsd**: enforce workflow write gates over MCP +- **sf**: surface warnings when DB or STATE.md init fails +- **sf**: create sf.db, runtime/, and STATE.md during init (#3880) +- **sf**: suppress workflow stderr during /sf +- **sf**: enforce workflow write gates over MCP - restore autoStartTime on resume + replace empty catch blocks (#3585) - **mcp**: harden workflow tool boundary -- **gsd**: accept em-dash none verification rationale -- **gsd**: resync managed resources on auto resume -- **gsd**: stop stale forensics context hijacks -- **gsd**: serialize workflow MCP execution state -- **gsd**: restore milestone status db preflight +- **sf**: accept em-dash none verification rationale +- **sf**: resync managed resources on auto resume +- **sf**: stop stale forensics context hijacks +- **sf**: serialize workflow MCP execution state +- **sf**: restore milestone status db preflight - **claude-code-cli**: suppress streamed internal tool noise -- **gsd**: skip same-path planning artifact copies +- **sf**: skip same-path planning artifact copies - **claude-code-cli**: suppress internal tool call noise - **pi-coding-agent**: avoid oauth login for api-key providers -- **gsd**: snapshot new untracked files before dispatch +- **sf**: snapshot new untracked files before dispatch - **platform**: harden command execution and stabilize onboarding sync - **pi-ai**: restore event stream factory export -- **gsd**: use valid codebase refresh logger -- **gsd**: auto-refresh codebase cache -- **gsd**: align model switching and prefs surfaces +- **sf**: use valid codebase refresh logger +- **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 -- **docs**: stop pointing repo documentation to gsd.build +- **docs**: stop pointing repo documentation to sf.build - add activeEngineId and activeRunDir to PausedSessionMetadata interface -- **gsd**: address QA round 4 -- **gsd**: address QA round 3 -- **gsd**: address QA round 2 -- **gsd**: address QA round 1 -- **gsd**: address review feedback from trek-e -- **gsd**: assess recovery from paused worktree state -- **gsd**: satisfy extension typecheck for interrupted recovery -- **gsd**: restore hook dispatch export and guided flow imports -- **gsd**: clear stale paused metadata in guided flow -- **gsd**: preserve interrupted-session resume mode +- **sf**: address QA round 4 +- **sf**: address QA round 3 +- **sf**: address QA round 2 +- **sf**: address QA round 1 +- **sf**: address review feedback from trek-e +- **sf**: assess recovery from paused worktree state +- **sf**: satisfy extension typecheck for interrupted recovery +- **sf**: restore hook dispatch export and guided flow imports +- **sf**: clear stale paused metadata in guided flow +- **sf**: preserve interrupted-session resume mode - preserve explicit interrupted-session resume mode - preserve step-mode and suppress stale paused resumes - suppress stale interrupted-session resume prompts @@ -306,14 +306,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **test**: align auto-loop test timers with updated session timeout -- **gsd**: repair CI after branch split -- **gsd**: repair CI after branch split -- **gsd**: repair CI after branch split -- **gsd**: fail closed for discussion gate enforcement -- **gsd**: harden auto merge recovery and session safety -- **gsd**: repair overlay, shortcut, and widget surfaces -- **gsd**: prevent stale workflow reconcile state writes -- **gsd**: align prompt contracts and validation flow +- **sf**: repair CI after branch split +- **sf**: repair CI after branch split +- **sf**: repair CI after branch split +- **sf**: fail closed for discussion gate enforcement +- **sf**: harden auto merge recovery and session safety +- **sf**: repair overlay, shortcut, and widget surfaces +- **sf**: prevent stale workflow reconcile state writes +- **sf**: align prompt contracts and validation flow - **pi-tui**: harden input parsing and editor focus behavior - **remote-questions**: cancel local TUI when remote answer wins the race - **auto**: increase session timeout to 120s and treat timeout as recoverable pause (#3767) @@ -323,14 +323,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **ui**: display 'anthropic-api' in model selector to distinguish from claude-code - **gates**: add mechanical enforcement for discussion question gates - **prompts**: harden non-bypassable gates and exclude dot-folders from scanning -- **gsd**: ignore filename headings in parsePlan +- **sf**: ignore filename headings in parsePlan - **providers**: match 'out of extra usage' error and respect claude-code provider in model resolution (#3772) - **pi-ai**: recover XML parameters trapped in JSON strings - **retry**: guard claude-code fallback to anthropic provider only - **providers**: route Anthropic subscription users through Claude Code CLI (#3772) - **claude-code**: use native Windows claude lookup -- **gsd**: suppress repeated preferences section warnings -- **gsd**: normalize described expected output paths +- **sf**: suppress repeated preferences section warnings +- **sf**: normalize described expected output paths - **auto**: resilient transient error recovery — defer to Core RetryHandler and fix cmdCtx race ## [2.66.1] - 2026-04-08 @@ -338,49 +338,49 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **pi-tui**: revert contentCursorRow, use hardwareCursorRow as movement baseline - **pi-tui**: use contentCursorRow for render movement baseline instead of cursorRow -- **gsd**: add logWarning to empty catch block in orphaned worktree cleanup -- **gsd**: add consecutiveFinalizeTimeouts to LoopState in journal tests -- **gsd**: add escalation and unit-detach guards to finalize timeout handlers -- **gsd**: add timeout guard around postUnitPreVerification to prevent auto-loop hang -- **gsd**: OS-specific keyboard shortcut hints via formatShortcut helper +- **sf**: add logWarning to empty catch block in orphaned worktree cleanup +- **sf**: add consecutiveFinalizeTimeouts to LoopState in journal tests +- **sf**: add escalation and unit-detach guards to finalize timeout handlers +- **sf**: add timeout guard around postUnitPreVerification to prevent auto-loop hang +- **sf**: OS-specific keyboard shortcut hints via formatShortcut helper - **subagent**: support list-style tools frontmatter - clear autocomplete rows from content bottom - parse annotated pre-exec file paths -- **gsd**: add orphaned milestone branch audit at auto-mode bootstrap +- **sf**: add orphaned milestone branch audit at auto-mode bootstrap ## [2.66.0] - 2026-04-08 ### Added -- **gsd**: add fast path for queued milestone discussion -- **gsd**: add /gsd show-config command +- **sf**: add fast path for queued milestone discussion +- **sf**: add /sf show-config command - **reactive**: graph diagnostics and subagent_model config - **dispatch**: parallel research slices and parallel milestone validation - **parallel**: worker model override for parallel milestone workers ### Fixed -- **gsd**: validate depth verification answer before unlocking write-gate -- **gsd**: revert unknown artifact check to warn-and-proceed -- **gsd**: add missing cmd field to test base WorkflowEvent -- **gsd**: address remaining adversarial review findings for wave 3 -- **gsd**: detect concurrent event log growth during reconcile -- **gsd**: address adversarial review findings for wave 3 -- **gsd**: address adversarial review findings for wave 2 -- **gsd**: address adversarial review findings for wave 1 -- **gsd**: WAL-safe migration backup + stronger regression tests -- **gsd**: consistency and cleanup (wave 5/5) -- **gsd**: write safety — atomic writes and randomized tmp paths (wave 4/5) -- **gsd**: session and recovery robustness (wave 3/5) -- **gsd**: event log and reconciliation robustness (wave 2/5) -- **gsd**: critical state machine data integrity fixes (wave 1/5) -- **gsd**: critical state machine data integrity fixes (wave 1/5) -- **gsd**: remove ecosystem research stub and address adversarial review -- **gsd**: suppress model change notification in auto-mode unless verbose -- **gsd**: exclude task.files from checkTaskOrdering to prevent false positives +- **sf**: validate depth verification answer before unlocking write-gate +- **sf**: revert unknown artifact check to warn-and-proceed +- **sf**: add missing cmd field to test base WorkflowEvent +- **sf**: address remaining adversarial review findings for wave 3 +- **sf**: detect concurrent event log growth during reconcile +- **sf**: address adversarial review findings for wave 3 +- **sf**: address adversarial review findings for wave 2 +- **sf**: address adversarial review findings for wave 1 +- **sf**: WAL-safe migration backup + stronger regression tests +- **sf**: consistency and cleanup (wave 5/5) +- **sf**: write safety — atomic writes and randomized tmp paths (wave 4/5) +- **sf**: session and recovery robustness (wave 3/5) +- **sf**: event log and reconciliation robustness (wave 2/5) +- **sf**: critical state machine data integrity fixes (wave 1/5) +- **sf**: critical state machine data integrity fixes (wave 1/5) +- **sf**: remove ecosystem research stub and address adversarial review +- **sf**: suppress model change notification in auto-mode unless verbose +- **sf**: exclude task.files from checkTaskOrdering to prevent false positives - **state**: skip ghost check for queued milestones in registry build - **ci**: replace empty catch blocks and raw stderr with logWarning - **logging**: add debugLog to empty catch in reopen-milestone - **state-machine**: 9 resilience fixes + 86 regression tests (#3161) -- **gsd**: add incremental persistence to discuss prompts +- **sf**: add incremental persistence to discuss prompts - replace empty catch with logWarning for silent-catch-diagnostics test - **test**: escape regex metacharacters in skip-by-preference pattern test - **test**: search for numbered step definitions in prompt ordering test @@ -388,71 +388,71 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **test**: update action count for note captures now included in results - **test**: remove extraneous test file from wrong branch - **test**: update worktree sync tests to use separate milestone IDs -- **gsd**: use valid LogComponent type for stale branch guard warning +- **sf**: use valid LogComponent type for stale branch guard warning - **test**: update rogue detection test for auto-remediation behavior - **test**: update stuck-planning test to expect executing after reconciliation - **test**: update file path consistency tests for inputs-only checking - **test**: add CONTEXT file to queued milestone ghost detection test - **test**: update needs-remediation test to expect validating-milestone phase -- **gsd**: import all-done milestones as complete during DB migration -- **gsd**: allow milestone completion when validation skipped by preference -- **gsd**: set slice sequence at all three insertion sites -- **gsd**: four prompt/runtime fixes for completion and session stability -- **gsd**: default insertMilestone status to queued instead of active -- **gsd**: suppress repeated frontmatter YAML parse warnings -- **gsd**: normalize list inputs in complete-task + fix roadmap dep parsing -- **gsd**: open DB before status derivation + respect isolation:none in quick -- **gsd**: add .bg-shell/ to baseline gitignore patterns +- **sf**: import all-done milestones as complete during DB migration +- **sf**: allow milestone completion when validation skipped by preference +- **sf**: set slice sequence at all three insertion sites +- **sf**: four prompt/runtime fixes for completion and session stability +- **sf**: default insertMilestone status to queued instead of active +- **sf**: suppress repeated frontmatter YAML parse warnings +- **sf**: normalize list inputs in complete-task + fix roadmap dep parsing +- **sf**: open DB before status derivation + respect isolation:none in quick +- **sf**: add .bg-shell/ to baseline gitignore patterns - **tui**: prevent Enter key infinite loop in interview notes mode - **provider**: handle Enter key to initiate auth setup in provider manager -- **gsd**: cap run-uat dispatch attempts to prevent infinite replay loop +- **sf**: cap run-uat dispatch attempts to prevent infinite replay loop - **mcp**: use createRequire to resolve SDK wildcard subpath imports -- **gsd**: mark note captures as executed in executeTriageResolutions -- **gsd**: validate main_branch preference exists before using in merge -- **gsd**: handle deleted cwd in projectRoot to prevent ENOENT crash -- **gsd**: skip current milestone in syncWorktreeStateBack to prevent merge conflicts -- **gsd**: add structuredQuestionsAvailable conditional to slice discuss -- **gsd**: restore full tool set after discuss flow scoping -- **gsd**: tighten verifyExpectedArtifact to prevent rogue-write false positives -- **gsd**: add verification gate to complete-slice tool -- **gsd**: fix pre-execution-checks false positives from backticks and task.files -- **gsd**: stop renderAllProjections from overwriting authoritative PLAN.md -- **gsd**: auto-checkout to main when isolation:none finds stale milestone branch -- **gsd**: auto-remediate stale slice DB status when SUMMARY exists on disk -- **gsd**: open DB on demand in gsd_milestone_status for non-auto sessions -- **gsd**: detect phantom milestones from abandoned gsd_milestone_generate_id -- **gsd**: force re-validation when verdict is needs-remediation -- **gsd**: exclude closed slices from findMissingSummaries check -- **gsd**: recover from stale lockfile after crash or SIGKILL -- **gsd**: add createdAt timestamp and 30s age guard to staleness check -- **gsd**: clear stale pendingAutoStart after /clear interrupts discussion -- **gsd**: suppress misleading warnings for expected ENOENT/EISDIR conditions -- **gsd**: extract real error from message content when errorMessage is useless -- **gsd**: extract real error from message content when errorMessage is useless -- **gsd**: show accurate pause message for queued-user-message skip -- **gsd**: treat queued-user-message skip as non-retryable interruption -- **gsd**: recognize "Not provided." default in isVerificationNotApplicable -- **gsd**: discoverManifests skips symlinked extension directories -- **gsd**: recognize "Not provided." default in isVerificationNotApplicable -- **gsd**: reconcile plan-file tasks into DB when planner skips persistence (#3600) -- **gsd**: use isClosedStatus() in dispatch guard instead of raw complete check +- **sf**: mark note captures as executed in executeTriageResolutions +- **sf**: validate main_branch preference exists before using in merge +- **sf**: handle deleted cwd in projectRoot to prevent ENOENT crash +- **sf**: skip current milestone in syncWorktreeStateBack to prevent merge conflicts +- **sf**: add structuredQuestionsAvailable conditional to slice discuss +- **sf**: restore full tool set after discuss flow scoping +- **sf**: tighten verifyExpectedArtifact to prevent rogue-write false positives +- **sf**: add verification gate to complete-slice tool +- **sf**: fix pre-execution-checks false positives from backticks and task.files +- **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**: force re-validation when verdict is needs-remediation +- **sf**: exclude closed slices from findMissingSummaries check +- **sf**: recover from stale lockfile after crash or SIGKILL +- **sf**: add createdAt timestamp and 30s age guard to staleness check +- **sf**: clear stale pendingAutoStart after /clear interrupts discussion +- **sf**: suppress misleading warnings for expected ENOENT/EISDIR conditions +- **sf**: extract real error from message content when errorMessage is useless +- **sf**: extract real error from message content when errorMessage is useless +- **sf**: show accurate pause message for queued-user-message skip +- **sf**: treat queued-user-message skip as non-retryable interruption +- **sf**: recognize "Not provided." default in isVerificationNotApplicable +- **sf**: discoverManifests skips symlinked extension directories +- **sf**: recognize "Not provided." default in isVerificationNotApplicable +- **sf**: reconcile plan-file tasks into DB when planner skips persistence (#3600) +- **sf**: use isClosedStatus() in dispatch guard instead of raw complete check - **browser-tools**: make sharp an optional lazy dependency -- **gsd**: pass required arguments in defer-milestone-stamp test -- **gsd**: replace remaining empty catch with logWarning -- **gsd**: use logWarning instead of raw stderr in catch blocks -- **gsd**: log error instead of empty catch in STATE.md rebuild -- **gsd**: log error instead of empty catch in skip_slice -- **gsd**: cast milestone classification to string for type safety -- **gsd**: treat zero-slice roadmap as pre-planning in guided flow -- **gsd**: rebuild STATE.md after skip-slice and strengthen rethink prompt -- **gsd**: use main_branch preference in worktree creation -- **gsd**: stamp defer and milestone captures as executed after triage +- **sf**: pass required arguments in defer-milestone-stamp test +- **sf**: replace remaining empty catch with logWarning +- **sf**: use logWarning instead of raw stderr in catch blocks +- **sf**: log error instead of empty catch in STATE.md rebuild +- **sf**: log error instead of empty catch in skip_slice +- **sf**: cast milestone classification to string for type safety +- **sf**: treat zero-slice roadmap as pre-planning in guided flow +- **sf**: rebuild STATE.md after skip-slice and strengthen rethink prompt +- **sf**: use main_branch preference in worktree creation +- **sf**: stamp defer and milestone captures as executed after triage - **tui**: treat absolute file paths as plain text, not commands - **tui**: break infinite re-render loop for images in cmux -- **gsd**: rebuild STATE.md before guided-flow dispatch -- **gsd**: defer queued shells in active milestone selection +- **sf**: rebuild STATE.md before guided-flow dispatch +- **sf**: defer queued shells in active milestone selection - **retry**: prevent 429 quota cascade and 30-min lockout -- **gsd**: add fastPathInstruction to buildDiscussMilestonePrompt loadPrompt call +- **sf**: add fastPathInstruction to buildDiscussMilestonePrompt loadPrompt call ### Changed - auto-commit after quick-task @@ -466,27 +466,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.65.0] - 2026-04-07 ### Added -- **gsd**: persistent notification panel with TUI overlay, widget, and web API -- **gsd**: wire blocking behavior and strict mode for enhanced verification -- **gsd**: add post-execution cross-task consistency checks -- **gsd**: add pre-execution plan verification checks +- **sf**: persistent notification panel with TUI overlay, widget, and web API +- **sf**: wire blocking behavior and strict mode for enhanced verification +- **sf**: add post-execution cross-task consistency checks +- **sf**: add pre-execution plan verification checks ### Fixed -- **gsd**: wrap long notification messages and fit overlay to content -- **gsd**: remove background color from backdrop, fix message truncation -- **gsd**: restore consistent overlay height to prevent ghost artifacts -- **gsd**: improve notification overlay backdrop and content-fit sizing -- **gsd**: only unlink notification lock when owned, prevent foreign lock deletion -- **gsd**: add backdrop dimming and viewport padding to notification overlay -- **gsd**: add intent + phase guards to resume context fallback (#3615) -- **gsd**: inject task context for unstructured resume prompts (#3615) +- **sf**: wrap long notification messages and fit overlay to content +- **sf**: remove background color from backdrop, fix message truncation +- **sf**: restore consistent overlay height to prevent ghost artifacts +- **sf**: improve notification overlay backdrop and content-fit sizing +- **sf**: only unlink notification lock when owned, prevent foreign lock deletion +- **sf**: add backdrop dimming and viewport padding to notification overlay +- **sf**: add intent + phase guards to resume context fallback (#3615) +- **sf**: inject task context for unstructured resume prompts (#3615) - **pi-coding-agent**: restore extension tools after session switch (#3616) - **agent-loop**: schema overload cap ignores bash execution errors (#3618) - **bg-shell**: prevent signal handler accumulation + cap alert queue -- **gsd**: coerce plain-string provides field to array in complete-slice (#3585) +- **sf**: coerce plain-string provides field to array in complete-slice (#3585) - address PR #3468 review findings -- **gsd**: persist autoStartTime across session resume so elapsed timer survives /exit -- **gsd**: add enhanced_verification preferences to mergePreferences +- **sf**: persist autoStartTime across session resume so elapsed timer survives /exit +- **sf**: add enhanced_verification preferences to mergePreferences - **headless**: treat discuss and plan as multi-turn commands ### Changed @@ -496,7 +496,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.64.0] - 2026-04-06 ### Added -- **gsd**: add LLM safety harness for auto-mode damage control +- **sf**: add LLM safety harness for auto-mode damage control - **ollama**: native /api/chat provider with full option exposure - **parallel**: slice-level parallelism with dependency-aware dispatch (#3315) - **mcp-client**: add OAuth auth provider for HTTP transport (#3295) @@ -504,20 +504,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **ui**: remove 200-column cap on welcome screen width - address adversarial review findings for #3576 -- **gsd**: replace hardcoded agent skill paths with dynamic resolution (#3575) +- **sf**: replace hardcoded agent skill paths with dynamic resolution (#3575) - **headless**: sync resources and use agent dir for query - **cli**: show latest version and bypass npm cache in update check -- **gsd**: follow CONTRIBUTING standards for #3565 -- **gsd**: address Codex adversarial review findings for #3565 -- **gsd**: coerce string arrays to objects in complete-slice/task tools (#3565) -- **gsd**: harden flat-rate routing guard against alias/resolution gaps +- **sf**: follow CONTRIBUTING standards for #3565 +- **sf**: address Codex adversarial review findings for #3565 +- **sf**: coerce string arrays to objects in complete-slice/task tools (#3565) +- **sf**: harden flat-rate routing guard against alias/resolution gaps - **pi-coding-agent**: register models.json providers and await Ollama probe in headless mode - **ollama**: use apiKey auth mode to avoid streamSimple crash -- **gsd**: disable dynamic model routing for flat-rate providers -- **gsd**: address Codex adversarial review findings -- **gsd**: prevent LLM from querying gsd.db directly via bash (#3541) -- **gsd**: seed requirements table from REQUIREMENTS.md on first update -- **gsd**: inject S##-CONTEXT.md from slice discussion into all prompt builders +- **sf**: disable dynamic model routing for flat-rate providers +- **sf**: address Codex adversarial review findings +- **sf**: prevent LLM from querying sf.db directly via bash (#3541) +- **sf**: seed requirements table from REQUIREMENTS.md on first update +- **sf**: inject S##-CONTEXT.md from slice discussion into all prompt builders - **cli**: guard model re-apply against session restore and async rejection - **pi-coding-agent**: resolve model fallback race that ignores configured provider (#3534) - **detection**: add xcodegen and Xcode bundle support to project detection (#1882) @@ -527,11 +527,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **web**: use safePackageRootFromImportUrl for cross-platform package root (#1881) (#1893) - isolate CmuxClient stdio to prevent TUI hangs in CMUX (#3306) - worktree health check walks parent dirs for monorepo support (#3313) -- **gsd**: promote milestone status from queued to active in plan-milestone (#3317) -- **worktree**: correct merge failure notification command from /complete-milestone to /gsd dispatch complete-milestone (#1901) +- **sf**: promote milestone status from queued to active in plan-milestone (#3317) +- **worktree**: correct merge failure notification command from /complete-milestone to /sf dispatch complete-milestone (#1901) - detect and block Gemini CLI OAuth tokens used as API keys (#3296) - **auto**: break retry loop on tool invocation errors (malformed JSON) (#3298) -- **git**: use git add -u in symlink .gsd fallback to prevent hang (#3299) +- **git**: use git add -u in symlink .sf fallback to prevent hang (#3299) - handle complete-slice context exhaustion to unblock downstream slices (#3300) - cap consecutive tool validation failures to prevent stuck-loop (#3301) - make enrichment tool params optional for limited-toolcall models (#3302) @@ -541,20 +541,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **preferences**: warn on silent parse failure for non-frontmatter files (#3310) - track remote-questions in managed-resources manifest (#3312) - **auto**: add timeout guard for postUnitPostVerification in runFinalize (#3314) -- **gsd**: handle large markdown parameters in complete-milestone JSON parsing (#3316) +- **sf**: handle large markdown parameters in complete-milestone JSON parsing (#3316) - **metrics**: deduplicate idle-watchdog entries and fix forensics false-positives (#1973) - prevent milestone/slice artifact rendering corruption (#3293) - **doctor**: strip --fix flag before positional parse (#1919) (#1926) - resolve external-state worktree DB path (#2952) (#3303) -- **gsd**: worktree teardown path validation prevents data loss (#3311) +- **sf**: worktree teardown path validation prevents data loss (#3311) - prevent auto-mode from dispatching deferred slices (#3309) - preserve completed slice status on plan-milestone re-plan (#3318) - reopen DB on cold resume, recognize heavy check mark (#3319) - dashboard model label shows dispatched model, not stale previous unit (#3320) ### Changed -- **gsd**: remove copyright line from test file -- **gsd**: trim promptGuidelines to 1 line to reduce per-turn token cost +- **sf**: remove copyright line from test file +- **sf**: trim promptGuidelines to 1 line to reduce per-turn token cost - **web**: consolidate subprocess boilerplate into shared runner (#1899) ## [2.63.0] - 2026-04-05 @@ -563,16 +563,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **mcp-server**: add 6 read-only tools for project state queries (#3515) ### Fixed -- **gsd**: enrich vague diagnostic messages with root-cause context +- **sf**: enrich vague diagnostic messages with root-cause context - **test**: reset dedup cache between ask-user-freetext tests -- **db**: delete orphaned WAL/SHM files alongside empty gsd.db (#2478) -- **gsd**: prevent auto-wrapup from interrupting in-flight tool calls (#3512) -- **gsd**: handle bare model IDs in resolveDefaultSessionModel (#3517) -- **gsd**: wrap decision and requirement saves in transaction to prevent ID races -- **gsd**: prefer PREFERENCES.md over settings.json for session bootstrap model (#3517) -- **gsd**: add Claude Code official skill directories to skill resolution +- **db**: delete orphaned WAL/SHM files alongside empty sf.db (#2478) +- **sf**: prevent auto-wrapup from interrupting in-flight tool calls (#3512) +- **sf**: handle bare model IDs in resolveDefaultSessionModel (#3517) +- **sf**: wrap decision and requirement saves in transaction to prevent ID races +- **sf**: prefer PREFERENCES.md over settings.json for session bootstrap model (#3517) +- **sf**: add Claude Code official skill directories to skill resolution - **dedup**: hash full question payload, not just IDs -- **gsd**: prevent duplicate ask_user_questions dispatches with per-turn dedup cache +- **sf**: prevent duplicate ask_user_questions dispatches with per-turn dedup cache - **pi-ai**: extend repairToolJson to handle XML tags and truncated numbers - **pi-coding-agent**: cancel stale retries after model switch @@ -582,13 +582,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.62.1] - 2026-04-05 ### Fixed -- **gsd**: gate steer worktree routing on active session, fix messaging -- **gsd**: resolve steer overrides to worktree path when worktree is active +- **sf**: gate steer worktree routing on active session, fix messaging +- **sf**: resolve steer overrides to worktree path when worktree is active ## [2.62.0] - 2026-04-04 ### Added -- **gsd**: enhance /gsd codebase with preferences, --collapse-threshold, and auto-init +- **sf**: enhance /sf codebase with preferences, --collapse-threshold, and auto-init - **01-05**: fire before_model_select hook, add verbose scoring output, load capability overrides - **01-04**: register before_model_select placeholder handler in SF hooks - **01-04**: add BeforeModelSelectEvent to extension API and wire emission @@ -598,13 +598,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **01-01**: add capability types, data tables, and scoring functions to model-router ### Fixed -- **gsd**: add codebase validation in validatePreferences so preferences are not silently dropped +- **sf**: add codebase validation in validatePreferences so preferences are not silently dropped - **test**: update db-path-worktree-symlink test for simplified diagnostic logging -- **gsd**: update tests for errors-only audit persistence, fix empty catch blocks -- **gsd**: harden audit log persistence — errors-only, sanitized, demote probe warnings -- **gsd**: address adversarial review findings on workflow-logger migration -- **gsd**: fail-closed stop guard, harden backtrack parsing, fix prompt params -- **gsd**: add diagnostic logging to empty catch blocks in auto-mode +- **sf**: update tests for errors-only audit persistence, fix empty catch blocks +- **sf**: harden audit log persistence — errors-only, sanitized, demote probe warnings +- **sf**: address adversarial review findings on workflow-logger migration +- **sf**: fail-closed stop guard, harden backtrack parsing, fix prompt params +- **sf**: add diagnostic logging to empty catch blocks in auto-mode - **lsp**: add legacy alias for renamed kotlin-language-server key - break infinite notes loop when selecting "None of the above" - align defaultRoutingConfig capability_routing to true @@ -613,8 +613,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **remote-questions**: fire configured channels in interactive mode ### Changed -- **gsd**: migrate all catch blocks to centralized workflow-logger -- init gsd +- **sf**: migrate all catch blocks to centralized workflow-logger +- init sf ## [2.61.0] - 2026-04-04 @@ -634,20 +634,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **extensions**: add Ollama extension for first-class local LLM support (#3371) -- **doctor**: stale commit safety check with gsd snapshot and auto-cleanup +- **doctor**: stale commit safety check with sf snapshot and auto-cleanup - **extensions**: wire up topological sort and unified registry filtering (#3152) - **widget**: add last commit display and dashboard layout improvements (#3226) - **model-routing**: enable dynamic routing by default (#3120) - **vscode**: sidebar redesign, SCM provider, checkpoints, diagnostics [3/3] - **splash**: add remote channel indicator to welcome screen tools row - stream full text and thinking output in headless verbose mode (#2934) -- **gsd**: add codebase map — structural orientation for fresh agent contexts +- **sf**: add codebase map — structural orientation for fresh agent contexts ### Fixed - **worktree**: resolve merge conflict for PR #3322 — adopt comprehensive pre-merge cleanup - **merge**: clean stale MERGE_HEAD before squash merge (#2912) - **state**: always run disk→DB reconciliation when DB is available (#2631) -- **git-service**: fix merge-base ancestry check and .gsd/ leakage in snapshot absorption +- **git-service**: fix merge-base ancestry check and .sf/ leakage in snapshot absorption - **extensions**: update provides.hooks in 7 extension manifests to match actual registrations (#3157) - surface nativeCommit errors in reconcileMergeState instead of silently swallowing (#3052) - **parallel**: scope commits to milestone boundaries in parallel mode (#3047) @@ -656,15 +656,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - detect and remove nested .git dirs in worktree cleanup to prevent data loss (#3044) - prevent data loss when git isolation default changes (#2625) (#3043) - **read-tool**: clamp offset to file bounds instead of throwing (#3007) (#3042) -- **gsd**: preserve queued milestones with worktrees in ghost detection (#3041) +- **sf**: preserve queued milestones with worktrees in ghost detection (#3041) - **compaction**: add chunked fallback when messages exceed model context window (#3038) - preserve interactive terminal across tab switches and project changes (#3055) - call cleanupQuickBranch on turn_end to squash-merge quick branch back (#3054) - align run-uat artifact path to ASSESSMENT, preventing false stuck retries (#3053) - replace invalid Discord invite links with canonical URL (#3056) - add Windows shell guard to remaining spawn sites (#3058) -- route `gsd auto` to headless runner to prevent hang on piped stdin/stdout (#3057) -- respect .gitignore for .gsd/ in rethink prompt (#3059) +- route `sf auto` to headless runner to prevent hang on piped stdin/stdout (#3057) +- respect .gitignore for .sf/ in rethink prompt (#3059) - migrate unit ownership from JSON to SQLite to eliminate read-modify-write race (#3061) - **roadmap**: handle numbered, bracketed, and indented prose H3 headers in slice parser (#3063) - add worktree-merge to resolveModelWithFallbacksForUnit switch and update KNOWN_UNIT_TYPES (#3066) @@ -678,7 +678,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **auto**: move selectAndApplyModel before updateProgressWidget (#3079) - 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 /gsd queue mode (#2545) (#3082) +- block work execution during /sf queue mode (#2545) (#3082) - detect worktree basePath in gsdRoot() to prevent escaping to project root (#3083) - invalidate stale quick-task captures across milestone boundaries (#3084) - defer model validation until after extensions register (#3089) @@ -696,7 +696,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - resolve OAuth API key in buildMemoryLLMCall via modelRegistry (#2959) (#3233) - **forensics**: read completion status from DB instead of legacy file (#3129) (#3234) - use camelCase parameter names in execute-task and complete-slice prompts (#2933) (#3236) -- check bootstrap completeness in init wizard gate, not just .gsd/ existence (#2942) (#3237) +- check bootstrap completeness in init wizard gate, not just .sf/ existence (#2942) (#3237) - specify write tool for PROJECT.md in milestone/slice prompts (#3238) - widen completing-milestone gate to accept "None required" and similar phrasings (#2931) (#3239) - prevent ask_user_questions from poisoning auto-mode dispatch (#2936) (#3240) @@ -711,8 +711,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - skip staleness rebuild in npm tarball installs (#2877) (#3250) - **parallel**: check worktree DB for milestone completion in merge (#2812) (#3256) - make claude-code provider stateful with full context and sidechain events (#2859) (#3254) -- **worktree**: preserve non-empty gsd.db during sync to prevent truncation (#2815) (#3255) -- align @gsd/native module type with compiled output (#3253) +- **worktree**: preserve non-empty sf.db during sync to prevent truncation (#2815) (#3255) +- 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) @@ -734,11 +734,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - correct OAuth fallback request shape for google_search (#2963) (#3272) - prevent UAT stuck-loop and orphaned worktree after milestone completion (#3065) - **mcp**: handle server names with spaces in mcp_discover (#3037) -- **gsd**: detect markdown body verdicts and guard plan-milestone against completed slices (#2960) (#3035) +- **sf**: detect markdown body verdicts and guard plan-milestone against completed slices (#2960) (#3035) - **error-classifier**: replace STREAM_RE whack-a-mole with catch-all V8 JSON.parse pattern - type _borderColorKey as 'dim' | 'bashMode' to match ThemeColor - **tui**: comprehensive TUI review — layout, flow, rendering, and state fixes -- **gsd**: harden codebase-map — bug fixes, UX polish, and expanded tests +- **sf**: harden codebase-map — bug fixes, UX polish, and expanded tests ### Changed - **state**: centralize pipeline logging through workflow logger (#3282) @@ -755,9 +755,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **auto-dispatch**: widen operational verification gate regex (fixes #2866) (#2898) - **parallel**: three bugs preventing reliable parallel worker execution (#2801) - **web**: fall back to project totals when dashboard metrics are zero (#2847) -- **gsd**: parse raw YAML under preference headings (#2794) -- **gsd**: persist verification classes in milestone validation (#2820) -- **gsd**: guard reconcileWorktreeDb against same-file ATTACH corruption (#2825) +- **sf**: parse raw YAML under preference headings (#2794) +- **sf**: persist verification classes in milestone validation (#2820) +- **sf**: guard reconcileWorktreeDb against same-file ATTACH corruption (#2825) - **web**: skip shutdown in daemon mode so server survives tab close (#2842) - **headless**: skip execution_complete for multi-turn commands (auto/next) - Fixed 3 bugs (launchd JSON parsing, login race condition, interact… @@ -778,12 +778,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **headless**: match "completed" status from RPC v2 in exit code mapper - show external drives in directory browser on Linux - Regenerate package-lock.json after merge -- **gsd**: resume cold auto bootstrap from db -- **gsd**: preserve first auto unit model after session reset +- **sf**: resume cold auto bootstrap from db +- **sf**: preserve first auto unit model after session reset - Accept flags after positional command in headless arg parser -- **gsd**: discover project subagents in .gsd +- **sf**: discover project subagents in .sf - **model-routing**: use honest unitTypes for discuss dispatches and map all auto-dispatch phases -- revert jsonl.ts to inline implementation — @gsd-build/rpc-client not available at source-level test time in CI +- revert jsonl.ts to inline implementation — @sf-build/rpc-client not available at source-level test time in CI ### Changed - auto-commit after complete-milestone @@ -791,15 +791,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.56.0] - 2026-03-27 ### Added -- **parallel**: /gsd parallel watch — native TUI overlay for worker monitoring (#2806) +- **parallel**: /sf parallel watch — native TUI overlay for worker monitoring (#2806) ### Fixed - **ci**: copy web/components to dist-test for xterm-theme test (#2891) -- **gsd**: prefer PREFERENCES.md in worktrees (#2796) -- **gsd**: resume auto-mode after transient provider pause (#2822) +- **sf**: prefer PREFERENCES.md in worktrees (#2796) +- **sf**: resume auto-mode after transient provider pause (#2822) - **parallel**: resolve session lock contention and 3 related parallel-mode bugs (#2184) (#2800) - **web**: improve light theme terminal contrast (#2819) -- **gsd**: preserve auto start model through discuss (#2837) +- **sf**: preserve auto start model through discuss (#2837) ### Changed - **test**: compile unit tests with esbuild, reclassify integration tests, fix node_modules symlink (#2809) @@ -811,13 +811,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - headless text mode observability + skip UAT pause (#2867) ### Fixed -- **cli**: let gsd update bypass version mismatch gate (#2845) +- **cli**: let sf update bypass version mismatch gate (#2845) - **contracts**: add isWorkspaceEvent guard + close routeLiveInteractionEvent exhaustiveness gap (#2878) -- **gsd**: use project root for prior-slice dispatch guard (#2863) -- **gsd**: include queue context in milestone planning prompts (#2846) +- **sf**: use project root for prior-slice dispatch guard (#2863) +- **sf**: include queue context in milestone planning prompts (#2846) - detect monorepo roots in project discovery to prevent workspace fragmentation (#2849) - **bg-shell**: recover from deleted cwd in timers (#2850) -- **gsd**: enable dynamic routing without models section (#2851) +- **sf**: enable dynamic routing without models section (#2851) - **interactive**: fully remove providers from /providers (#2852) ## [2.54.0] - 2026-03-27 @@ -830,7 +830,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **vscode**: activity feed, workflow controls, session forking, enhanced code lens [2/3] (#2656) -- **gsd**: enable safety mechanisms by default (snapshots, pre-merge checks) (#2678) +- **sf**: enable safety mechanisms by default (snapshots, pre-merge checks) (#2678) ### Fixed - hydrate collected secrets for current session (#2788) @@ -843,11 +843,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - write milestone reports to project root instead of worktree (#2778) - auto-resolve build artifact conflicts in milestone merge (#2777) - let rate-limit errors attempt model fallback before pausing (#2775) -- prevent gsd next from self-killing via stale crash lock (#2784) +- prevent sf next from self-killing via stale crash lock (#2784) - add shell flag for Windows spawn in VSCode extension (#2781) ### Changed -- **gsd**: extract duplicated status guards and validation helpers (#2767) +- **sf**: extract duplicated status guards and validation helpers (#2767) ## [2.52.0] - 2026-03-27 @@ -864,33 +864,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - make transaction() re-entrant and add slice_dependencies to initSchema - remove preferences.md from ROOT_STATE_FILES to prevent back-sync overwrite - wire tool handlers through DB port layer, remove _getAdapter from all tools -- **gsd**: move state machine guards inside transaction in 5 tool handlers (#2752) +- **sf**: move state machine guards inside transaction in 5 tool handlers (#2752) - reconcile disk milestones into empty DB before deriveStateFromDb guard (#2686) -- **gsd**: seed preferences.md into auto-mode worktrees (#2693) +- **sf**: seed preferences.md into auto-mode worktrees (#2693) - **claude-import**: discover marketplace plugins nested inside container directories (#2718) - exempt interactive tools from idle watchdog stall detection (#2676) - guard allSlicesDone against vacuous truth on empty slice array (#2679) - block complete-milestone dispatch when VALIDATION is needs-remediation (#2682) -- **gsd**: sync milestone DB status in parkMilestone and unparkMilestone (#2696) +- **sf**: sync milestone DB status in parkMilestone and unparkMilestone (#2696) - **web**: auth token gate — synthetic 401 on missing token, unauthenticated boot state, and recovery screen (#2740) - **remote-questions**: empty-key entry in auth.json shadows valid Discord bot token (#2737) - idle watchdog stalled-tool detection overridden by filesystem activity (#2697) - surface exhausted Claude SDK streams as errors (#2719) - **docker**: overhaul fragile setup, adopt proven container patterns (#2716) -- **gsd**: write DB before disk in validate-milestone to match engine pattern (#2742) -- **gsd**: extract and honor milestone argument in /gsd auto and /gsd next (#2729) +- **sf**: write DB before disk in validate-milestone to match engine pattern (#2742) +- **sf**: extract and honor milestone argument in /sf auto and /sf next (#2729) - **windows**: prevent EINVAL by disabling detached process groups on Win32 (#2744) -- **gsd**: delete orphaned verification_evidence rows on complete-task rollback (#2746) -- **gsd**: wire setLogBasePath into engine init to resurrect audit log (#2745) +- **sf**: delete orphaned verification_evidence rows on complete-task rollback (#2746) +- **sf**: wire setLogBasePath into engine init to resurrect audit log (#2745) - Remove premature pendingTools.delete in webSearchResult handler (#2743) -- **gsd**: remove redundant assertions that fail TS2367 typecheck +- **sf**: remove redundant assertions that fail TS2367 typecheck - include preferences.md in worktree sync and initial seed ### Changed - **pi-ai**: replace model-ID pattern matching with capability metadata (#2548) -- **gsd-db**: comprehensive SQLite audit fixes — indexes, caching, safety, reconciliation +- **sf-db**: comprehensive SQLite audit fixes — indexes, caching, safety, reconciliation - rename preferences.md to PREFERENCES.md for consistency (#2700) (#2738) -- **gsd**: unify three overlapping error classifiers into single classify→decide→act pipeline +- **sf**: unify three overlapping error classifiers into single classify→decide→act pipeline ## [2.51.0] - 2026-03-26 @@ -918,7 +918,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - update triage-dispatch static analysis tests for enqueueSidecar helper - **notifications**: prefer terminal-notifier over osascript on macOS (#2633) - classify stream-truncation JSON parse errors as transient (#2636) -- call ensureDbOpen() before slice queries in /gsd discuss (#2640) +- call ensureDbOpen() before slice queries in /sf discuss (#2640) - **prompts**: use --body-file for forensics issue creation (#2641) - isLockProcessAlive should return true for own PID (#2642) - check ASSESSMENT file for UAT verdict in checkNeedsRunUat (#2646) @@ -958,9 +958,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **skills**: address QA round 3 - **skills**: address QA round 2 - **skills**: defer greenfield skill selection to post-design phase -- **skills**: add migration from ~/.gsd/agent/skills/ to ~/.agents/skills/ -- **gsd extension**: detect initialized projects in health widget -- **gsd extension**: detect initialized projects in health widget +- **skills**: add migration from ~/.sf/agent/skills/ to ~/.agents/skills/ +- **sf extension**: detect initialized projects in health widget +- **sf extension**: detect initialized projects in health widget ### Changed - consolidate docs, remove stale artifacts, and repo hygiene (#2665) @@ -969,7 +969,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.50.0] - 2026-03-26 ### Added -- **gsd**: wire structured error propagation through UnitResult +- **sf**: wire structured error propagation through UnitResult - add parallel quality gate evaluation with evaluating-gates phase - add 8-question quality gates to planning and completion templates @@ -979,23 +979,23 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - use Record for hasNonEmptyFields to accept typed DB rows - **tests**: replace undefined assertTrue/assertEq with assert.ok/assert.equal - **tests**: replace undefined assertTrue/assertEq with assert.ok/deepStrictEqual -- **gsd**: handle session_switch event so /resume restores SF state (#2587) +- **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) - **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 -- **gsd**: prevent ensureGsdSymlink from creating subdirectory .gsd when git-root .gsd exists +- **sf**: prevent ensureGsdSymlink 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 - add SAFE_SKILL_NAME guard to reject prompt-injection via crafted skill names -- **gsd**: use explicit parameter syntax in skill activation prompts +- **sf**: use explicit parameter syntax in skill activation prompts - guard writeIntegrationBranch against workflow-template branches - preserve doctor missing-dir checks for active legacy slices -- **gsd**: downgrade isolation mode when worktree creation fails -- **gsd**: skip loading files for completed milestones in queue context builder +- **sf**: downgrade isolation mode when worktree creation fails +- **sf**: skip loading files for completed milestones in queue context builder - resolve race conditions in blob-store, discovery-cache, and agent-loop - **ai**: resolve WebSocket listener leaks and bound session cache - **rpc**: resolve double-set race, missing error ID, and stream handler @@ -1023,7 +1023,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.49.0] - 2026-03-25 ### Added -- add --yolo flag to /gsd auto for non-interactive project init +- add --yolo flag to /sf auto for non-interactive project init ### Fixed - use full git log in merge tests to match trailer-based milestone IDs @@ -1037,14 +1037,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.48.0] - 2026-03-25 ### Added -- **discuss**: allow /gsd discuss to target queued milestones -- enhance /gsd forensics with journal and activity log awareness +- **discuss**: allow /sf discuss to target queued milestones +- enhance /sf forensics with journal and activity log awareness ### Fixed - make journal scanning intelligent — limit parsed files, line-count older ones - **model-registry**: scope custom provider stream handlers to prevent clobbering built-in API handlers - **forensics**: filter benign bash exit-code-1 and user skips from error traces -- **gsd**: clear stale milestone ID reservations at session start +- **sf**: clear stale milestone ID reservations at session start - render tool calls above text response for external providers - **auto**: skip CONTEXT-DRAFT warning for completed/parked milestones @@ -1063,7 +1063,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **claude-code-cli**: render tool calls above text response - **ci**: update FILE-SYSTEM-MAP.md path after docs→docs-internal move -- isInheritedRepo false negative when parent has stale .gsd; defense-in-depth local .git check in bootstrap +- isInheritedRepo false negative when parent has stale .sf; defense-in-depth local .git check in bootstrap - **claude-code-cli**: resolve SDK executable path and update model IDs - make planning doctrine demoable definition audience-appropriate - **prompts**: migrate remaining 4 prompts to use DB-backed tool API instead of direct write @@ -1075,7 +1075,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **ci**: prevent windows-portability from blocking pipeline - **ci**: prevent pipeline race condition on release push -- **gsd**: create empty DB for fresh projects with empty .gsd/ (#2510) +- **sf**: create empty DB for fresh projects with empty .sf/ (#2510) - **remote-questions**: hydrate remote channel tokens from auth.json on startup ### Changed @@ -1085,64 +1085,64 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.46.0] - 2026-03-25 ### Added -- **gsd**: single-writer engine v3 — state machine guards, actor identity, reversibility -- **gsd**: single-writer state engine v2 — discipline layer on DB architecture -- **gsd**: add workflow-logger and wire into engine, tool, manifest, reconcile paths (#2494) +- **sf**: single-writer engine v3 — state machine guards, actor identity, reversibility +- **sf**: single-writer state engine v2 — discipline layer on DB architecture +- **sf**: add workflow-logger and wire into engine, tool, manifest, reconcile paths (#2494) ### Fixed -- **gsd**: align prompts with single-writer tool API -- **gsd**: integration-proof — check DB state not roadmap projection after reset -- **gsd**: block milestone completion when verification fails (#2500) +- **sf**: align prompts with single-writer tool API +- **sf**: integration-proof — check DB state not roadmap projection after reset +- **sf**: block milestone completion when verification fails (#2500) - **ci**: add typecheck:extensions to pretest to prevent silent type drift -- **gsd**: relax integration-proof cross-validation for table-format roadmap -- **gsd**: update integration-proof tests for table-format roadmap projections -- **gsd**: update test assertions for schema v11, prompt changes, and removed completedUnits -- **gsd**: update test files for removed completedUnits, writeLock signature, and type changes -- **gsd**: remove stale completedUnits refs, fix writeLock callers, add missing imports -- **gsd**: harden single-writer engine — close TOCTOU, intercept bypasses, status inconsistencies +- **sf**: relax integration-proof cross-validation for table-format roadmap +- **sf**: update integration-proof tests for table-format roadmap projections +- **sf**: update test assertions for schema v11, prompt changes, and removed completedUnits +- **sf**: update test files for removed completedUnits, writeLock signature, and type changes +- **sf**: remove stale completedUnits refs, fix writeLock callers, add missing imports +- **sf**: harden single-writer engine — close TOCTOU, intercept bypasses, status inconsistencies - **write-intercept**: close bare-relative-path bypass in STATE.md regex - **voice**: fix misleading portaudio error on PEP 668 Linux systems (#2403) (#2407) - **core**: address PR review feedback for non-apikey provider support (#2452) - **ci**: retry npm install in pipeline to handle registry propagation delay (#2462) -- **gsd**: change default isolation mode from worktree to none (#2481) +- **sf**: change default isolation mode from worktree to none (#2481) - **loader**: add startup checks for Node version and git availability (#2463) -- **gsd**: add worktree lifecycle events to journal (#2486) +- **sf**: add worktree lifecycle events to journal (#2486) ## [2.45.0] - 2026-03-25 ### Added - **web**: make web UI mobile responsive (#2354) -- **gsd**: add `/gsd rethink` command for conversational project reorganization (#2459) -- **gsd**: add renderCall/renderResult previews to DB tools (#2273) +- **sf**: add `/sf rethink` command for conversational project reorganization (#2459) +- **sf**: add renderCall/renderResult previews to DB tools (#2273) - add timestamps on user and assistant messages (#2368) -- **gsd**: add `/gsd mcp` command for MCP server status and connectivity (#2362) +- **sf**: add `/sf mcp` command for MCP server status and connectivity (#2362) - complete offline mode support (#2429) -- **system-context**: inject global ~/.gsd/agent/KNOWLEDGE.md into system prompt (#2331) +- **system-context**: inject global ~/.sf/agent/KNOWLEDGE.md into system prompt (#2331) ### Fixed -- **gsd**: handle retentionDays=0 on Windows + run windows-portability on PRs (#2460) +- **sf**: handle retentionDays=0 on Windows + run windows-portability on PRs (#2460) - use Array.from instead of Buffer.from for native processStreamChunk state (#2348) -- **gsd**: isInheritedRepo conflates ~/.gsd with project .gsd when git root is $HOME (#2398) +- **sf**: isInheritedRepo conflates ~/.sf with project .sf when git root is $HOME (#2398) - reconcile disk milestones missing from DB in deriveStateFromDb (#2416) (#2422) - **auto**: reset recoveryAttempts on unit re-dispatch (#2322) (#2424) - detect and preserve submodule state during worktree teardown (#2337) (#2425) - **auto-start**: handle survivor branch recovery in phase=complete (#2358) (#2427) -- **gsd**: widen test search window for CRLF portability on Windows (#2458) -- **gsd**: preserve rich task plans on DB roundtrip (#2450) (#2453) +- **sf**: widen test search window for CRLF portability on Windows (#2458) +- **sf**: preserve rich task plans on DB roundtrip (#2450) (#2453) - merge worktree back to main when stopAuto is called after milestone completion (#2317) (#2430) -- **gsd**: skip doctor directory checks for pending slices (#2446) -- **gsd**: migrate completion/validation prompts to DB-backed tools (#2449) -- **gsd**: prevent saveArtifactToDb from overwriting larger files with truncated content (#2442) (#2447) +- **sf**: skip doctor directory checks for pending slices (#2446) +- **sf**: migrate completion/validation prompts to DB-backed tools (#2449) +- **sf**: prevent saveArtifactToDb from overwriting larger files with truncated content (#2442) (#2447) - stop auto loop on real code merge conflicts (#2330) (#2428) - classify terminated/connection errors as transient in provider error handler (#2309) (#2432) - archive completed-units.json on milestone transition and sync metrics.json (#2313) (#2431) - supervision timeouts now respect task est: annotations (#2243) (#2434) - auto_pr: true now actually creates PRs — fix 3 interacting bugs (#2302) (#2433) -- **gsd**: insert DB row when generating milestone ID (#2416) -- **gsd**: reconcile disk-only milestones into DB in deriveStateFromDb (#2416) +- **sf**: insert DB row when generating milestone ID (#2416) +- **sf**: reconcile disk-only milestones into DB in deriveStateFromDb (#2416) - **preferences**: deduplicate unrecognized format warning on repeated loads (#2375) - gate auto-mode bootstrap on SQLite availability (#2419) (#2421) -- block /gsd quick when auto-mode is active (#2420) +- block /sf quick when auto-mode is active (#2420) - **ci**: add Rust target for all platforms, not just cross-compilation - **ci**: restore Rust target triple and separate cross-compilation setup - **ci**: separate cross-compilation target from toolchain install @@ -1150,12 +1150,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - migrate D-G test files from createTestContext to node:test (#2418) - **test**: replace try/finally with beforeEach/afterEach in packages tests (#2390) -- **test**: migrate gsd/tests s-z from custom harness to node:test (#2397) -- **test**: migrate gsd/tests o-r from custom harness to node:test (#2401) -- **test**: migrate gsd/tests i-n from custom harness to node:test (#2399) -- **test**: migrate gsd/tests a-c from custom harness to node:test (#2400) -- **test**: replace try/finally with t.after() in gsd/tests (e-i) (#2396) -- **test**: replace try/finally with t.after() in gsd/tests (a-d) (#2395) +- **test**: migrate sf/tests s-z from custom harness to node:test (#2397) +- **test**: migrate sf/tests o-r from custom harness to node:test (#2401) +- **test**: migrate sf/tests i-n from custom harness to node:test (#2399) +- **test**: migrate sf/tests a-c from custom harness to node:test (#2400) +- **test**: replace try/finally with t.after() in sf/tests (e-i) (#2396) +- **test**: replace try/finally with t.after() in sf/tests (a-d) (#2395) - **test**: replace try/finally with t.after() in src/tests (o-z) (#2392) - **test**: replace try/finally with t.after() in src/tests (a-n) (#2394) @@ -1164,9 +1164,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **core**: support for 'non-api-key' provider extensions like Claude Code CLI (#2382) - **docker**: add official Docker sandbox template for isolated SF auto mode (#2360) -- **gsd**: show per-prompt token cost in footer behind show_token_cost preference (#2357) +- **sf**: show per-prompt token cost in footer behind show_token_cost preference (#2357) - **web**: add "Change project root" button to web UI (#2355) -- **gsd**: Tool-driven write-side state transitions — replace markdown mutation with atomic SQLite tool calls (#2141) +- **sf**: Tool-driven write-side state transitions — replace markdown mutation with atomic SQLite tool calls (#2141) - **S06/T02**: Strip all 16 lazy createRequire fallback paths from migr… - **S05/T04**: Migrate remaining 6 callers (auto-prompts, auto-recovery… - **S05/T03**: Migrate 7 warm/cold callers (doctor, doctor-checks, visu… @@ -1176,35 +1176,35 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **S04/T02**: Migrate dispatch-guard.ts to DB queries with isDbAvailab… - **S01/T03**: Migrate planning prompts to DB-backed tool guidance and… - **S01/T01**: Partially advanced schema v8 groundwork and documented t… -- **gsd**: tool-driven write-side state transitions (M001) +- **sf**: tool-driven write-side state transitions (M001) ### Fixed - post-migration cleanup — pragmas, rollbacks, tool gaps, stale code (#2410) - **test**: normalize CRLF in auto-stash-merge assertion for Windows - **test**: swallow EPERM on Windows temp dir cleanup in auto-stash-merge test -- **gsd**: add file-based fallbacks for DB-dependent code paths and fix CI test failures -- **gsd**: remove stale observabilityIssues reference in journal-integration test +- **sf**: add file-based fallbacks for DB-dependent code paths and fix CI test failures +- **sf**: remove stale observabilityIssues reference in journal-integration test - **extensions**: detect TypeScript syntax in .js extension files and suggest renaming to .ts (#2386) -- **gsd**: prevent planning data loss from destructive upsert and post-unit re-import (#2370) -- **gsd**: use correct notify severity type ("warning" not "warn") +- **sf**: prevent planning data loss from destructive upsert and post-unit re-import (#2370) +- **sf**: use correct notify severity type ("warning" not "warn") - **web**: resolve compiled .js modules for all subprocess calls under node_modules (#2320) - **test**: increase perf assertion threshold to prevent CI flake (#2327) - add missing SQLite WAL sidecars and journal to runtime exclusion lists (#2299) -- **gsd**: remove stale observability validator + fix greenfield worktree check +- **sf**: remove stale observability validator + fix greenfield worktree check - **memory**: fix memory and resource leaks across TUI, LSP, DB, and automation (#2314) -- **gsd**: preserve freeform DECISIONS.md content on decision save (#2319) +- **sf**: preserve freeform DECISIONS.md content on decision save (#2319) - **pi-ai**: restore alibaba-coding-plan provider via models.custom.ts (#2350) - **doctor**: skip false env_dependencies error in auto-worktrees (#2318) -- **gsd**: auto-stash dirty files before squash merge and surface dirty filenames in error (#2298) -- **gsd**: keep params as any in db-tools executors (CI tsconfig is stricter) -- **gsd**: replace any types in db-tools executor signatures -- **gsd**: resolve 4 TS compilation errors from parser migration -- **gsd**: wrap plan-task DB writes in transaction + untrack .gsd/ artifacts +- **sf**: auto-stash dirty files before squash merge and surface dirty filenames in error (#2298) +- **sf**: keep params as any in db-tools executors (CI tsconfig is stricter) +- **sf**: replace any types in db-tools executor signatures +- **sf**: resolve 4 TS compilation errors from parser migration +- **sf**: wrap plan-task DB writes in transaction + untrack .sf/ artifacts - **S04/T04**: Add planning-crossval tests proving DB↔rendered↔parsed pa… - **S04/T01**: Add schema v9 migration with sequence column on slices/ta… -- remove .gsd/ milestone artifacts from git index +- remove .sf/ milestone artifacts from git index - **tests**: update remediation step assertions and crossval fixture -- **gsd**: address all 7 review findings from PR #2141 +- **sf**: address all 7 review findings from PR #2141 - **tests**: remove invalid `seq` property from insertMilestone calls ### Changed @@ -1236,16 +1236,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - prevent banner from printing twice on first run (#2251) - **test**: Windows CI — use double quotes in git commit message (#2252) - **async-jobs**: suppress duplicate follow-up for awaited job results (#2248) (#2250) -- **gsd**: remove force-staging of .gsd/milestones/ through symlinks (#2247) (#2249) -- **gsd**: remove over-broad skill activation heuristic (#2239) (#2244) +- **sf**: remove force-staging of .sf/milestones/ through symlinks (#2247) (#2249) +- **sf**: remove over-broad skill activation heuristic (#2239) (#2244) - **auth**: fall through to env/fallback when OAuth credential has no registered provider (#2097) - **lsp**: bound message buffer and clean up stale client state (#2171) -- clean up macOS numbered .gsd collision variants (#2205) (#2210) +- clean up macOS numbered .sf collision variants (#2205) (#2210) - **search**: keep duplicate-search loop guard armed (#2117) - clean up extension error listener on session dispose (#2165) - **web**: resolve 4 pre-existing onboarding contract test failures (#2209) - async bash job timeout hangs indefinitely instead of erroring out (#2214) -- **gsd**: apply fast service tier outside auto-mode (#2126) +- **sf**: apply fast service tier outside auto-mode (#2126) - **interactive**: clean up leaked SIGINT and extension selector listeners (#2172) - **ci**: standardize GitHub Actions and Node.js versions (#2169) - **native**: resolve memory leaks in glob, ttsr, and image overflow (#2170) @@ -1256,7 +1256,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **web**: kill stale server process before launch to prevent EADDRINUSE (#1934) (#2034) - **git**: force LC_ALL=C in GIT_NO_PROMPT_ENV to support non-English locales (#2035) - **forensics**: force gh CLI for issue creation to prevent misrouting (#2067) (#2094) -- force-stage .gsd/milestones/ artifacts when .gsd is a symlink (#2104) (#2112) +- force-stage .sf/milestones/ artifacts when .sf is a symlink (#2104) (#2112) - **pi-ai**: correct Copilot context window and output token limits (#2118) ### Changed @@ -1265,11 +1265,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.42.0] - 2026-03-22 ### Added -- **gsd**: declarative workflow engine — YAML-defined workflows through the auto-loop (#2024) -- **gsd**: unified rule registry, event journal, journal query tool, and tool naming convention (#1928) +- **sf**: declarative workflow engine — YAML-defined workflows through the auto-loop (#2024) +- **sf**: unified rule registry, event journal, journal query tool, and tool naming convention (#1928) - **ci**: PR risk checker — classify changed files by system and surface risk level (#1930) - ADR attribution — distinguish human vs agent vs collaborative decisions (#1830) -- add /gsd fast command and gate service tier icon to supported models (#1848) (#1862) +- add /sf fast command and gate service tier icon to supported models (#1848) (#1862) - add --host, --port, --allowed-origins flags for web mode (#1847) (#1873) ### Fixed @@ -1287,7 +1287,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **web**: persist auth token in sessionStorage to survive page refreshes (#1877) - clean up SQUASH_MSG after squash-merge and guard worktree teardown against uncommitted changes (#1868) - populate RecoveryContext in hook unit supervision to prevent crash on stalled tool recovery (#1867) -- resolve worktree path from git registry when .gsd/ symlink is shadowed (#1866) +- resolve worktree path from git registry when .sf/ symlink is shadowed (#1866) - resolve Node v24 web boot failure — ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (#1864) - **auto**: broaden worktree health check to all ecosystems (#1860) - **doctor**: cascade slice uncheck when task_done_missing_summary unchecks tasks (#1850) (#1858) @@ -1309,7 +1309,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **ci**: skip build/test for docs-only PRs and add prompt injection scan (#1699) - **docs**: add Custom Models guide and update related documentation (#1670) - surface doctor issue details in progress score widget and health views (#1667) -- **cleanup**: add ~/.gsd/projects/ orphan detection and pruning (#1686) +- **cleanup**: add ~/.sf/projects/ orphan detection and pruning (#1686) ### Fixed - skip web build on Windows — Next.js webpack hits EPERM on system dirs @@ -1319,9 +1319,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **auto**: reject execute-task with zero tool calls as hallucinated (#1838) - also convert --import resolver path to file URL for Windows - use pathToFileURL for Windows-safe ESM import in verification-gate test -- **gsd**: read depends_on from CONTEXT-DRAFT.md when CONTEXT.md is absent (#1743) +- **sf**: read depends_on from CONTEXT-DRAFT.md when CONTEXT.md is absent (#1743) - **roadmap**: detect ✓ completion marker in prose slice headers (#1816) -- **auto**: reverse-sync root-level .gsd files on worktree teardown (#1831) +- **auto**: reverse-sync root-level .sf files on worktree teardown (#1831) - **tui**: prevent freeze when using @ file finder (#1832) - prevent silent data loss when milestone merge fails due to dirty working tree (#1752) - **verification**: avoid DEP0190 by passing command to shell explicitly (#1827) @@ -1336,11 +1336,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - use realpathSync.native on Windows to resolve 8.3 short paths - detect and skip ghost milestone directories in deriveState() (#1817) - create milestone directory when triage defers to a not-yet-existing milestone (#1813) -- add @gsd/pi-tui to test module resolver in dist-redirect (#1811) +- add @sf/pi-tui to test module resolver in dist-redirect (#1811) - surface unmapped active requirements when all milestones complete (#1805) - normalize paths in tests to handle Windows 8.3 short-path forms (#1804) - share milestone ID reservation between preview and tool (#1569) (#1802) -- **tui,gsd**: tool-call loop guard + TUI stack overflow prevention (#1801) +- **tui,sf**: tool-call loop guard + TUI stack overflow prevention (#1801) - validate paused-session milestone before restoring it (#1664) (#1800) - detect REPLAN-TRIGGER.md in deriveState for triage-initiated replans (#1798) - dispatch uat targets last completed slice instead of activeSlice (#1693) (#1796) @@ -1373,27 +1373,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - prevent getLoadedSkills crash and auto-build workspace packages (#1767) - session lock multi-path cleanup and false positive hardening (#1578) (#1765) - robust node_modules symlink handling to prevent extension loading failures (#1762) -- lazy-load @gsd/pi-tui in shared/ui.ts to prevent /exit crash (#1761) +- lazy-load @sf/pi-tui in shared/ui.ts to prevent /exit crash (#1761) - validate worktree .git file and fix metrics toolCall casing (#1713) (#1754) - verify implementation artifacts before milestone completion (#1703) (#1760) - make task closeout crash-safe by unchecking orphaned checkboxes (#1650) (#1759) - preserve milestone branch on merge-back during transitions (#1573) (#1758) - write crash lock after newSession so it records correct session path (#1757) -- handle symlinked .gsd in git add pathspec exclusions (#1712) (#1756) +- handle symlinked .sf in git add pathspec exclusions (#1712) (#1756) - guard worktree teardown on empty merge to prevent data loss (#1672) (#1755) - resolve symlinks in doctor orphaned-worktree check (#1715) (#1753) - silence spurious extension load error for non-extension libraries (#1709) (#1747) - reset completion state when post_unit_hooks retry_on signal is consumed (#1746) -- route needs-discussion phase to showSmartEntry, preventing infinite /gsd loop (#1745) +- route needs-discussion phase to showSmartEntry, preventing infinite /sf loop (#1745) - **roadmap**: parse table-format slices in roadmap files (#1741) - extract milestone title from CONTEXT.md when ROADMAP is missing (#1729) -- **gsd**: harden auto-mode telemetry — metrics idempotency, elapsed guard, title sanitization (#1722) -- **gsd**: make saveJsonFile atomic via write-tmp-rename pattern (#1719) -- **gsd**: syncWorktreeStateBack recurses into tasks/ subdirectory (#1678) (#1718) +- **sf**: harden auto-mode telemetry — metrics idempotency, elapsed guard, title sanitization (#1722) +- **sf**: make saveJsonFile atomic via write-tmp-rename pattern (#1719) +- **sf**: syncWorktreeStateBack recurses into tasks/ subdirectory (#1678) (#1718) - prevent parallel worktree path resolution from escaping to home directory (#1677) - add web search budget awareness to discuss and queue prompts (#1702) - harden auto-mode against stale integration metadata and Windows file locks (#1633) -- **autocomplete**: repair /gsd skip, add widget/next completions, add discuss to hint (#1675) +- **autocomplete**: repair /sf skip, add widget/next completions, add discuss to hint (#1675) - **search**: keep loop guard armed after firing to prevent infinite loop restart (#1671) (#1674) - **worktree**: detect default branch instead of hardcoding "main" on milestone merge (#1668) (#1669) - remove duplicate TUI header rendered on session_start (#1663) @@ -1426,21 +1426,21 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.39.0] - 2026-03-20 ### Added -- **gsd**: activate matching skills in dispatched prompts (#1630) -- **gsd**: add .gsd/RUNTIME.md template for declared runtime context (#1626) -- **gsd**: create draft PR on milestone completion when git.auto_pr enabled (#1627) -- **gsd**: add browser-executable and runtime-executable UAT types (#1620) +- **sf**: activate matching skills in dispatched prompts (#1630) +- **sf**: add .sf/RUNTIME.md template for declared runtime context (#1626) +- **sf**: create draft PR on milestone completion when git.auto_pr enabled (#1627) +- **sf**: add browser-executable and runtime-executable UAT types (#1620) - apply model preferences in guided flow for milestone planning (#1614) -- **gsd**: GitHub sync extension — auto-sync to Issues, PRs, Milestones (#1603) +- **sf**: GitHub sync extension — auto-sync to Issues, PRs, Milestones (#1603) - add SF_PROJECT_ID env var to override project hash (#1600) -- add SF_HOME env var to override global ~/.gsd directory (#1566) -- **gsd**: add 13 enhancements to /gsd doctor (#1583) +- add SF_HOME env var to override global ~/.sf directory (#1566) +- **sf**: add 13 enhancements to /sf doctor (#1583) - feat(ui): minimal SF welcome screen on startup (#1584) ### Fixed -- recover + prevent #1364 .gsd/ data-loss (v2.30.0–v2.38.0) (#1635) +- recover + prevent #1364 .sf/ data-loss (v2.30.0–v2.38.0) (#1635) - treat summary as terminal artifact even when roadmap slices are unchecked (#1632) -- **gsd**: close residual #1364 data-loss vectors on v2.36.0+ (#1637) +- **sf**: close residual #1364 data-loss vectors on v2.36.0+ (#1637) - auto-resolve npm subpath exports in extension loader (#1624) - create node_modules symlink for dynamic import resolution in extensions (#1623) - filter cross-milestone errors from health tracker escalation (#1621) @@ -1451,33 +1451,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - return "dispatched" after doctor heal to prevent session race (#1580) (#1610) - update Anthropic OAuth endpoints to platform.claude.com (#1608) - lazy-open SF database on first tool call in manual sessions (#1606) -- **gsd**: detect anthropic-vertex in provider doctor (#1598) -- **gsd**: tighten prompt automation contracts (#1556) -- **gsd**: harden auto-mode agent loop — session teardown, unit correlation, sidecar perf (#1592) +- **sf**: detect anthropic-vertex in provider doctor (#1598) +- **sf**: tighten prompt automation contracts (#1556) +- **sf**: harden auto-mode agent loop — session teardown, unit correlation, sidecar perf (#1592) - break remaining shared/mod.js barrel imports in report generation chain (#1588) - apply pi manifest opt-out to extension-discovery.ts (#1545) -- detect worktree paths resolved through .gsd symlinks (#1585) +- detect worktree paths resolved through .sf symlinks (#1585) ### Changed -- **gsd**: unify sidecar mini-loop into main dispatch path (#1617) +- **sf**: unify sidecar mini-loop into main dispatch path (#1617) - **auto-loop**: initial cleanup — hoist constant, cache prefs per iteration (#1616) -- **gsd**: add 30K char hard cap on prompt preamble (#1619) -- **gsd**: replace stuck counter with sliding-window detection (#1618) +- **sf**: add 30K char hard cap on prompt preamble (#1619) +- **sf**: replace stuck counter with sliding-window detection (#1618) - **auto-loop**: 5 code smell fixes (#1602) -- **gsd**: replace session-scoped promise bridge with per-unit one-shot (#1595) -- **gsd**: remove prompt compression subsystem (~4,100 lines) (#1597) -- **gsd**: crashproof stopAuto with independent try/catch per cleanup step (#1596) +- **sf**: replace session-scoped promise bridge with per-unit one-shot (#1595) +- **sf**: remove prompt compression subsystem (~4,100 lines) (#1597) +- **sf**: crashproof stopAuto with independent try/catch per cleanup step (#1596) ## [2.38.0] - 2026-03-20 ### Added -- **gsd**: ADR-004 — derived-graph reactive task execution (#1546) +- **sf**: ADR-004 — derived-graph reactive task execution (#1546) - add anthropic-vertex provider for Claude on Vertex AI (#1533) ### Fixed - **ci**: reduce GitHub Actions minutes ~60-70% (~10k → ~3-4k/month) (#1552) -- **gsd**: reactive batch verification + dependency-based carry-forward (#1549) -- **gsd**: enforce backtick file paths in task plan IO sections (#1548) +- **sf**: reactive batch verification + dependency-based carry-forward (#1549) +- **sf**: enforce backtick file paths in task plan IO sections (#1548) ## [2.37.1] - 2026-03-20 @@ -1493,7 +1493,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **dashboard**: two-column layout with redesigned widget (#1530) -- integrate cmux with gsd runtime (#1532) +- integrate cmux with sf runtime (#1532) ### Fixed - add session-level search budget to prevent unbounded native web search (#1309) (#1529) @@ -1508,30 +1508,30 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - preserve user messages during abort with origin-aware queue clearing (#1439) (#1521) - remove broken SwiftUI skill and add CI reference check (#1476) (#1520) - wire escalateTier into auto-loop retry path (#1505) (#1519) -- prevent bare /gsd from stealing session lock from running auto-mode (#1507) (#1517) -- wire dead token-profile defaults and add /gsd rate command (#1505) (#1516) +- prevent bare /sf from stealing session lock from running auto-mode (#1507) (#1517) +- wire dead token-profile defaults and add /sf rate command (#1505) (#1516) - prevent false-positive session lock loss during sleep/event loop stalls (#1512) (#1513) -- **gsd**: filter non-milestone directories from findMilestoneIds (#1494) (#1508) -- **gsd**: accept 'passed' as terminal validation verdict (#1429) (#1509) +- **sf**: filter non-milestone directories from findMilestoneIds (#1494) (#1508) +- **sf**: accept 'passed' as terminal validation verdict (#1429) (#1509) - add missing imports breaking CI build (#1511) -- prevent ensureGitignore from adding .gsd when tracked in git (#1364) (#1367) +- prevent ensureGitignore from adding .sf when tracked in git (#1364) (#1367) - check project root .env when secrets gate runs in worktree (#1387) (#1470) - realign cwd before dispatch + clean stale merge state on failure (#1389) (#1400) - create milestones/ directory in worktree when missing (#1374) - inject network_idle warning into hook prompts (#1345) (#1401) - verify symlink after migration + fix test failures (#1377) (#1404) - validate CWD instead of project root when running from a SF worktree (#1317) (#1504) -- **gsd**: detect initialized health widget projects (#1432) -- smarter .gsd root discovery — git-root anchor + walk-up replaces symlink hack (#1386) +- **sf**: detect initialized health widget projects (#1432) +- smarter .sf root discovery — git-root anchor + walk-up replaces symlink hack (#1386) - correct SF-WORKFLOW.md fallback path and sync to agentDir (#1375) - always include reasoning.encrypted_content for OpenAI reasoning models -- **gsd**: avoid EISDIR crash in file loader -- **gsd**: open existing database on inspect +- **sf**: avoid EISDIR crash in file loader +- **sf**: open existing database on inspect ## [2.35.0] - 2026-03-19 ### Added -- **gsd**: add /gsd changelog command with LLM-summarized release notes (#1465) +- **sf**: add /sf changelog command with LLM-summarized release notes (#1465) ### Fixed - restore lsp single-server selector export @@ -1577,7 +1577,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - harden quick-task branch lifecycle — disk recovery + integration branch guard (#1342) - skip verification retry on spawn infra errors (ETIMEDOUT, ENOENT) (#1340) - keep external SF state stable in worktrees (#1334) -- stop excluding all .gsd/ from commits — only exclude runtime files (#1326) (#1328) +- stop excluding all .sf/ from commits — only exclude runtime files (#1326) (#1328) - handle ECOMPROMISED in uncaughtException guard and align retry onCompromised (#1322) (#1332) ## [2.33.1] - 2026-03-19 @@ -1597,7 +1597,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - align retry lock path with primary lock settings to prevent ECOMPROMISED (#1307) - skip symlinks in makeTreeWritable to prevent EPERM on NixOS/nix-darwin (#1303) -- handle Windows EPERM on .gsd migration rename with copy+delete fallback (#1296) +- handle Windows EPERM on .sf migration rename with copy+delete fallback (#1296) - add actionable recovery guidance to crash info messages (#1295) - resolve main repo root in worktrees for stable identity hash (#1294) - merge quick-task branch back to original after completion (#1293) @@ -1618,7 +1618,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - skip crash recovery when auto.lock was written by current process (#1289) - load worktree-cli extension modules via jiti instead of static ESM imports (#1285) -- **gsd**: prevent concurrent dispatch during skip chains (#1272) (#1283) +- **sf**: prevent concurrent dispatch during skip chains (#1272) (#1283) - skip non-artifact UAT dispatch in auto-mode (#1277) ### Changed @@ -1631,7 +1631,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.31.2] - 2026-03-18 ### Fixed -- **gsd**: stop replaying completed run-uat units (#1270) +- **sf**: stop replaying completed run-uat units (#1270) ## [2.31.1] - 2026-03-18 @@ -1648,13 +1648,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - remove stale git-commit assertion in worktree test after commit_docs removal - remove commit_docs test that broke CI after type removal (#1258) -- replace blanket git clean .gsd/ with targeted runtime file removal (#1252) +- replace blanket git clean .sf/ with targeted runtime file removal (#1252) - invalidate caches inside discuss loop to detect newly written slice context (#1249) - robust prose slice header parsing — handle H1-H4, bold, dots, no-separator variants (#1248) -- clean up stranded .gsd.lock/ directory to prevent false lock conflicts (#1251) +- clean up stranded .sf.lock/ directory to prevent false lock conflicts (#1251) ### Changed -- remove dead commit_docs preference (incompatible with external .gsd/ state) (#1258) +- remove dead commit_docs preference (incompatible with external .sf/ state) (#1258) ## [2.30.0] - 2026-03-18 @@ -1662,7 +1662,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - add extension manifest + registry for user-managed enable/disable (#1238) - add model health indicator to auto-mode progress widget (#1232) - simplify auto pipeline — merge research into planning, mechanical completion (ADR-003) (#1235) -- add create-gsd-extension skill (#1229) +- add create-sf-extension skill (#1229) - add built-in skill authoring system (ADR-003) (#1228) - **prefs**: two-step provider→model picker in preferences wizard (#1218) - workflow templates — right-sized workflows for every task type (#1185) @@ -1670,17 +1670,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - align react-best-practices skill name with directory name (#1234) - gate slice progression on UAT verdict, not just file existence (#1241) -- invalidate caches before roadmap check in /gsd discuss (#1240) +- invalidate caches before roadmap check in /sf discuss (#1240) - use shell: true for LSP spawn on Windows to resolve .cmd executables (#1233) - increase headless new-milestone timeout and limit investigation scope (#1230) -- clean untracked .gsd/ files before squash-merge to prevent failure (#1239) +- clean untracked .sf/ files before squash-merge to prevent failure (#1239) - graceful fallback when native addon is unavailable on unsupported platforms (#1225) - replace ambiguous double-question in discussion reflection step (#1226) - kill non-persistent bg processes between auto-mode units (#1217) - Two-column dashboard layout with task checklist (#1195) ### Changed -- move .gsd/ to external state directory with symlink (ADR-002) (#1242) +- move .sf/ to external state directory with symlink (ADR-002) (#1242) - replace MCPorter with native MCP client (#1210) - extend json-persistence utility and migrate top JSON I/O callsites (#1216) - deduplicate dispatchDoctorHeal — keep single copy in commands-handlers.ts (#1211) @@ -1694,19 +1694,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **ci**: automate prod-release with version bump, changelog, and tag push (#1194) - auto-open HTML reports in default browser on manual export (#1164) - upgrade to Node.js 24 LTS across CI, Docker, and package config (#1165) -- add /gsd logs command to browse activity, debug, and metrics logs (#1162) +- add /sf logs command to browse activity, debug, and metrics logs (#1162) - **browser-tools**: configurable screenshot resolution, format, and quality (#1152) - add pre-commit secret scanner and CI secret detection (#1148) -- **mcporter**: add .gsd/mcp.json per-project MCP config support (#1141) +- **mcporter**: add .sf/mcp.json per-project MCP config support (#1141) - **metrics**: add API request counter for copilot/subscription users (#1140) - per-milestone depth verification + queue-flow write-gate (#1116) - add OSC 8 clickable hyperlinks for file paths in export notifications (#1114) - park/discard actions for in-progress milestones (#1107) - **ci**: implement three-stage promotion pipeline (Dev → Test → Prod) (#1098) - cache-ordered prompt assembly and dashboard cache hit rate (#1094) -- add comprehensive API key manager (/gsd keys) (#1089) +- add comprehensive API key manager (/sf keys) (#1089) - **ci**: add multi-stage Dockerfile for CI builder and runtime images -- **gsd**: add directory safeguards for system/home paths (#1053) +- **sf**: add directory safeguards for system/home paths (#1053) - enhance HTML report with derived metrics, visualizations, and interactivity (#1078) - auto-extract lessons to KNOWLEDGE.md on slice/milestone completion (#711) (#1081) - auto-create PR on milestone completion (#687) (#1084) @@ -1717,7 +1717,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **prefs**: add search_provider to preferences.md (#1001) - add `--events` flag for JSONL stream filtering (#1000) - add 10 bundled skills for UI, quality, and code optimization (#999) -- **ux**: group model list by provider in /gsd prefs wizard (#993) +- **ux**: group model list by provider in /sf prefs wizard (#993) - add `--answers` flag for headless answer injection (#982) - add project onboarding detection and init wizard @@ -1745,7 +1745,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - prevent concurrent SF sessions from overlapping on same project (#1154) - exclude completion-transition errors from health escalation at task level (#1157) - **ci**: skip git-diff guard in prepublishOnly during CI (#1160) -- /gsd quick respects git isolation: none preference (#1156) +- /sf quick respects git isolation: none preference (#1156) - text-based fallbacks for RPC mode where TUI widgets produce empty turns (#1112) - **headless-query**: use jiti to load extension .ts modules (#1143) - pause auto-mode when env variables needed instead of blocking (#1147) @@ -1760,10 +1760,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - dispatch guard skips parked milestones — they no longer block later milestone dispatch (#1126) - worktree reassess-roadmap loop — existsSync fallback in checkNeedsReassessment (#1117) - **lsp**: use where.exe on Windows to resolve command paths (#1134) -- **gsd-db**: auto-initialize database when tools are called (#1133) +- **sf-db**: auto-initialize database when tools are called (#1133) - inline preferences path to fix remote questions setup (#1110) (#1111) - **ci**: add safe.directory for containerized pipeline job (#1108) -- remove .gsd/ from tracking, ignore entire directory +- remove .sf/ from tracking, ignore entire directory - update tests for god-file decomposition - strip model variant suffix for API key auth (#1097) (#1099) - match both milestoneId and sliceId when filtering duplicate blocker cards @@ -1773,7 +1773,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - reject prose Verify: fields from being executed as shell commands (#1066) (#1068) - restore session model on error instead of reading stale global prefs (#1065) (#1067) - prevent run-uat re-dispatch loop when roadmap checkbox update fails (#1063) (#1064) -- inline compareSemver in gsd extension to fix broken relative import (#1058) +- inline compareSemver in sf extension to fix broken relative import (#1058) - disable reasoning for MiniMax-M2.5 in alibaba-coding-plan provider (#1003) (#1055) - improve LSP diagnostics when no servers detected (#1082) (#1086) - prevent summarizing phase stall by retrying dropped agent_end events (#1072) @@ -1792,20 +1792,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **security**: use execFile for browser URL opening to prevent shell injection (#1022) - prevent duplicate milestone IDs when generating multiple before persisting (#961) (#1018) - consolidate duplicate formatting functions (#1011) -- **gsd**: delete orphaned complexity.ts (#1005) +- **sf**: delete orphaned complexity.ts (#1005) - **search**: consolidate duplicate Brave API helpers (#1010) - merge worktree to main when all milestones complete (#962) (#1007) -- **gsd**: deduplicate resolveGitHeadPath function (#1015) +- **sf**: deduplicate resolveGitHeadPath function (#1015) - add missing package.json subpath exports and oauth stubs (#1014) -- **gsd**: consolidate string-array normalizer functions into shared utility (#1009) +- **sf**: consolidate string-array normalizer functions into shared utility (#1009) - **browser-tools**: document intentional silent catches, add debug logging for others (#1013) - consolidate duplicate VerificationCheck/Result type definitions (#1008) -- **gsd**: add GIT_NO_PROMPT_ENV to gitFileExec and deduplicate constant (#1006) +- **sf**: add GIT_NO_PROMPT_ENV to gitFileExec and deduplicate constant (#1006) - **remote-questions**: add null coalesce for optional threadUrl (#1004) - auto-resume on transient server errors, not just rate limits (#886) (#957) - replace ambiguous compound question in reflection step (#963) (#1002) -- **gsd**: remove STATE.md update instructions from all prompts (#983) -- **gsd**: clear all caches after discuss dispatch so picker sees new CONTEXT files (#981) +- **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) - unify extension discovery logic (#995) @@ -1846,9 +1846,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **resource-loader**: extract syncResourceDir to eliminate triplicated sync logic (#1036) - **bg-shell**: split 1604-line god file into tool, command, and lifecycle modules (#1049) - **headless**: split 772-line god file into events, UI, and context modules (#1047) -- **gsd**: extract safeCopy/safeMkdir helpers to replace repetitive try/catch FS patterns (#1043) -- **gsd**: extract atomicWriteSync utility to replace 6 duplicate write-tmp-rename patterns (#1046) -- **gsd**: unify duplicate padRight/truncate into shared format-utils (#1045) +- **sf**: extract safeCopy/safeMkdir helpers to replace repetitive try/catch FS patterns (#1043) +- **sf**: extract atomicWriteSync utility to replace 6 duplicate write-tmp-rename patterns (#1046) +- **sf**: unify duplicate padRight/truncate into shared format-utils (#1045) - **loader**: consolidate 5 duplicate package.json version reads into cached helper (#1042) - **headless**: remove duplicate jsonLine, use serializeJsonLine from pi-coding-agent (#1039) - fix unicode regex discrepancy and standardize function naming (#1031) @@ -1861,9 +1861,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.28.0] - 2026-03-17 ### Added -- `gsd headless query` command for instant, read-only state inspection — returns phase, cost, progress, and next-unit as parseable JSON without spawning an LLM session -- `/gsd update` slash command for in-session self-update -- `/gsd export --html --all` for retrospective milestone reports +- `sf headless query` command for instant, read-only state inspection — returns phase, cost, progress, and next-unit as parseable JSON without spawning an LLM session +- `/sf update` slash command for in-session self-update +- `/sf export --html --all` for retrospective milestone reports ### Fixed - Failure recovery & resume safeguards: atomic file writes, OAuth fetch timeouts (30s), RPC subprocess exit detection, extension command context guards, bash temp file cleanup, settings write queue flush, LSP init retry with backoff, crash detection on session resume, blob garbage collection @@ -1899,7 +1899,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Web search loop broken with consecutive duplicate guard - Transient network errors retried before model fallback - Parallel worker PID tracking, spawn-status race, and exit persistence -- `/gsd discuss` now recommends next undiscussed slice +- `/sf discuss` now recommends next undiscussed slice - Roadmap parser allows suffix text after `## Slices` heading - User's model choice no longer overwritten when API key is temporarily unavailable - Reassess-roadmap skip loop broken by preventing re-persistence of evicted keys @@ -1922,7 +1922,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Model selector grouped by provider with model type, provider, and API docs fields - `require_slice_discussion` option to pause auto-mode before each slice for human review -- Discussion status indicators in `/gsd discuss` slice picker +- 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 - Alt+V clipboard image paste shortcut on macOS @@ -1945,7 +1945,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Extended idle timeout for headless new-milestone - EPIPE handling in LSP sendNotification with proper process exit wait - Debug logging for silent early-return paths in dispatchNextUnit -- Untracked .gsd/ state files removed before milestone merge checkout +- 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 - Unique temp paths in saveFile() to prevent parallel write collisions @@ -1988,7 +1988,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Headless `new-milestone` command for programmatic milestone creation - Interactive update prompt on startup when a new version is available - Symlink-based development workflow for `src/resources/` -- Descriptions added to `/gsd` autocomplete commands +- Descriptions added to `/sf` autocomplete commands - `validate-milestone` phase and dispatch ### Fixed @@ -2013,8 +2013,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **VS Code extension** — full extension with chat participant, RPC integration, marketplace publishing under FluxLabs publisher -- **`gsd headless`** — redesigned headless mode for full workflow orchestration: auto-responds to prompts, detects completion, supports `--json` output and `--timeout` flags -- **`gsd sessions`** — interactive session picker for browsing and resuming saved sessions (#721) +- **`sf headless`** — redesigned headless mode for full workflow orchestration: auto-responds to prompts, detects completion, supports `--json` output and `--timeout` flags +- **`sf sessions`** — interactive session picker for browsing and resuming saved sessions (#721) - **10 new browser tools** — `browser_save_pdf`, `browser_save_state`, `browser_restore_state`, `browser_mock_route`, `browser_block_urls`, `browser_clear_routes`, `browser_emulate_device`, `browser_extract`, `browser_visual_diff`, `browser_zoom_region`, `browser_generate_test`, `browser_check_injection`, `browser_action_cache` (#698) - **Structured discussion rounds** — `ask_user_questions` in guided-discuss-milestone for better requirement gathering (#688) - **`validate-milestone` prompt** — milestone validation prompt and template @@ -2041,7 +2041,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.22.0] - 2026-03-16 ### Added -- **`/gsd forensics`** — post-mortem investigation of auto-mode failures with structured root-cause analysis +- **`/sf forensics`** — post-mortem investigation of auto-mode failures with structured root-cause analysis - **Claude marketplace import** — import Claude marketplace plugins as namespaced SF components - **MCP server mode** — run SF as an MCP server with `--mode mcp` - **`/review` skill** — code review with diff-aware context @@ -2094,17 +2094,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Telegram remote questions** — receive and respond to SF questions via Telegram bot alongside existing Slack and Discord channels (#645) -- **`/gsd quick`** — execute a quick task with SF guarantees (atomic commits, state tracking) without the full planning overhead (#437) -- **`/gsd mode`** — workflow mode system with solo and team presets that configure defaults for milestone IDs, git commit behavior, and documentation settings (#651) -- **`/gsd help`** — categorized command reference with descriptions for all SF subcommands (#630) -- **`/gsd doctor`** — 7 runtime health checks with auto-fix for common state corruption issues (#646) +- **`/sf quick`** — execute a quick task with SF guarantees (atomic commits, state tracking) without the full planning overhead (#437) +- **`/sf mode`** — workflow mode system with solo and team presets that configure defaults for milestone IDs, git commit behavior, and documentation settings (#651) +- **`/sf help`** — categorized command reference with descriptions for all SF subcommands (#630) +- **`/sf doctor`** — 7 runtime health checks with auto-fix for common state corruption issues (#646) - **Agent instructions injection** — `agent-instructions.md` loaded into every agent session for persistent per-project behavioral guidance (#437) - **Skill lifecycle management** — telemetry tracking, health dashboard, and heal-skill command for managing custom skills (#599) - **SQLite context store** — surgical prompt injection from structured knowledge base for precise context engineering (#619) - **Context-window budget engine** — proportional prompt sizing that allocates context budget across system prompt sections based on relevance (#660) - **LSP activated by default** — Language Server Protocol now auto-activates with call hierarchy, formatting, signature help, and synchronized edits (#639) - **Extension smoke tests** — CI catches import failures, circular deps, and module resolution issues across all bundled extensions -- **`gsd --debug` mode** — structured JSONL diagnostic logging for troubleshooting dispatch and state issues (#468) +- **`sf --debug` mode** — structured JSONL diagnostic logging for troubleshooting dispatch and state issues (#468) - **Worktree post-create hook** — run custom setup scripts when SF creates a new worktree (#597) ### Fixed @@ -2125,8 +2125,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.19.0] - 2026-03-16 ### Added -- **Workflow visualizer** — `/gsd visualize` opens a full-screen TUI overlay with four tabs: Progress (milestone/slice/task tree), Dependencies (ASCII dep graph), Metrics (cost/token bar charts), and Timeline (chronological execution history). Supports Tab/1-4 switching, per-tab scrolling, auto-refresh every 2s, and optional auto-trigger after milestone completion via `auto_visualize` preference (#626) -- **Mid-execution capture & triage** — `/gsd capture` lets you fire-and-forget thoughts during auto-mode. The system triages accumulated captures at natural seams between tasks, classifies impact into five types (quick-task, inject, defer, replan, note), and proposes action with user confirmation. Dashboard shows pending capture count badge. Capture context injected into replan and reassess prompts (#512) +- **Workflow visualizer** — `/sf visualize` opens a full-screen TUI overlay with four tabs: Progress (milestone/slice/task tree), Dependencies (ASCII dep graph), Metrics (cost/token bar charts), and Timeline (chronological execution history). Supports Tab/1-4 switching, per-tab scrolling, auto-refresh every 2s, and optional auto-trigger after milestone completion via `auto_visualize` preference (#626) +- **Mid-execution capture & triage** — `/sf capture` lets you fire-and-forget thoughts during auto-mode. The system triages accumulated captures at natural seams between tasks, classifies impact into five types (quick-task, inject, defer, replan, note), and proposes action with user confirmation. Dashboard shows pending capture count badge. Capture context injected into replan and reassess prompts (#512) - **Dynamic model routing** — complexity-based model routing classifies units into light/standard/heavy tiers and routes to cheaper models when appropriate, reducing token consumption 20-50% on capped plans. Includes budget-pressure-aware routing, cross-provider cost comparison, escalation on failure, adaptive learning from routing history (rolling 50-entry window with user feedback support), and task plan introspection (code block counting, complexity keyword detection) (#579) - **Feature-branch lifecycle integration test** — proves milestone worktrees branch from and merge back to feature branches, never touching main (#624) - **Discord integration parity with Slack** — plus new remote-questions documentation (#620) @@ -2139,8 +2139,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.18.0] - 2026-03-16 ### Added -- **Milestone queue reorder** — `/gsd queue` supports reordering milestone execution priority with dependency-aware validation, persistent ordering via `.gsd/QUEUE-ORDER.json` (#460) -- **`.gsd/KNOWLEDGE.md`** — persistent project-specific context file loaded into agent prompts. New `/gsd knowledge` command with `rule`, `pattern`, and `lesson` subcommands for adding entries (#585) +- **Milestone queue reorder** — `/sf queue` supports reordering milestone execution priority with dependency-aware validation, persistent ordering via `.sf/QUEUE-ORDER.json` (#460) +- **`.sf/KNOWLEDGE.md`** — persistent project-specific context file loaded into agent prompts. New `/sf knowledge` command with `rule`, `pattern`, and `lesson` subcommands for adding entries (#585) - **Dynamic model discovery** — runtime model enumeration from provider APIs (Ollama, OpenAI, Google, OpenRouter) with per-provider TTL caching and discovery adapters. New `ProviderManagerComponent` TUI for managing providers with auth status and model counts (#581) - **Expanded preferences wizard** — all configurable fields now exposed in the setup wizard, model ID validation, and `updatePreferencesModels()` for safe read-modify-write of model config (#580) - **Comprehensive documentation** — 12 new docs covering getting started, auto-mode, commands, configuration, token optimization, cost management, git strategy, team workflows, skills, migration, troubleshooting, and architecture (#605) @@ -2166,7 +2166,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Token optimization profiles** — `budget`, `balanced`, and `quality` presets that coordinate model selection, phase skipping, and context compression to reduce token usage by 40-60% on budget mode - **Complexity-based task routing** — automatically classifies tasks as simple/standard/heavy and routes to appropriate models, with persistent learning from routing history -- **`git.commit_docs` preference** — set to `false` to keep `.gsd/` planning artifacts local-only, useful for teams where only some members use SF +- **`git.commit_docs` preference** — set to `false` to keep `.sf/` planning artifacts local-only, useful for teams where only some members use SF ### Changed - Updated Ollama cloud provider model catalog @@ -2180,7 +2180,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.16.0] - 2026-03-15 ### Added -- `/gsd steer` command — hard-steer plan documents during execution without stopping the pipeline +- `/sf steer` command — hard-steer plan documents during execution without stopping the pipeline - Native git operations via libgit2 — ~70 fewer process spawns per dispatch cycle - Native performance optimizations for `deriveState`, JSONL parsing, and path resolution - Default model upgraded to Opus 4.6 with 1M context variant @@ -2230,7 +2230,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Executor agents now receive explicit working directory, preventing writes to main repo instead of worktree (#543) -- Merge loop and .gsd/ conflict auto-resolution in worktree model, `git.isolation` preference restored (#536) +- Merge loop and .sf/ conflict auto-resolution in worktree model, `git.isolation` preference restored (#536) - Arrow keys no longer insert escape sequences as text during LLM streaming (#493) - YAML preferences parser hardened for OpenRouter model IDs with special characters (#488) - `@` file autocomplete debounced to prevent TUI freeze on large codebases (#448) @@ -2249,13 +2249,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.14.3] - 2026-03-15 ### Fixed -- **Copy planning artifacts into new auto-worktrees** — `createAutoWorktree` now copies `.gsd/milestones/`, `DECISIONS.md`, `REQUIREMENTS.md`, `PROJECT.md` from the source repo into the worktree. Prevents plan-slice loops in projects with pre-v2.14.0 `.gitignore`. +- **Copy planning artifacts into new auto-worktrees** — `createAutoWorktree` now copies `.sf/milestones/`, `DECISIONS.md`, `REQUIREMENTS.md`, `PROJECT.md` from the source repo into the worktree. Prevents plan-slice loops in projects with pre-v2.14.0 `.gitignore`. ## [2.14.2] - 2026-03-15 ### Fixed - **Dispatch reentrancy deadlock** — `_dispatching` flag was never reset after first dispatch, permanently blocking all subsequent unit dispatches. Wrapped in try/finally. -- **`.gitignore` self-heal** — existing projects with blanket `.gsd/` ignore now auto-remove it on next auto-mode start, replacing with explicit runtime-only patterns so planning artifacts are tracked in git. +- **`.gitignore` self-heal** — existing projects with blanket `.sf/` ignore now auto-remove it on next auto-mode start, replacing with explicit runtime-only patterns so planning artifacts are tracked in git. - **Discuss depth verification** — render summary as chat text (markdown renders), use ask_user_questions for short confirmation only. ## [2.14.1] - 2026-03-15 @@ -2268,13 +2268,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Discussion manifest** — mechanical process verification for multi-milestone context discussions -- **Session-internal `/gsd config`** — configure SF settings within a running session +- **Session-internal `/sf config`** — configure SF settings within a running session - **Model selection UI** — select list instead of free-text input for model preferences - **Startup performance** — faster SF launch via optimized initialization ### Changed - **Branchless worktree architecture** — eliminated slice branches entirely. All work commits sequentially on `milestone/` within auto-mode worktrees. No branch creation, switching, or merging within a worktree. ~2600 lines of merge/conflict/branch-switching code removed. -- **`.gitignore` overhaul** — planning artifacts (`.gsd/milestones/`) are tracked in git naturally. Only runtime files are gitignored. No more force-add hacks. +- **`.gitignore` overhaul** — planning artifacts (`.sf/milestones/`) are tracked in git naturally. Only runtime files are gitignored. No more force-add hacks. - **Multi-milestone enforcement** — `depends_on` frontmatter enforced in multi-milestone CONTEXT.md ### Fixed @@ -2283,8 +2283,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Dispatch recovery hardening** — artifact fallback when completion key missing, TUI freeze prevention on cascading skips, reentrancy guard, atomic writes, stale runtime record cleanup, git index.lock cleanup - **Hook orchestration** — finalize runtime records, add supervision, fix retry - **Empty slice plan stays in planning** — no longer incorrectly transitions to summarizing -- **Prefs wizard** — launch directly from `/gsd prefs`, fix parse/serialize cycle for empty arrays -- **Discussion routing** — `/gsd discuss` routes to draft when phase is needs-discussion +- **Prefs wizard** — launch directly from `/sf prefs`, fix parse/serialize cycle for empty arrays +- **Discussion routing** — `/sf discuss` routes to draft when phase is needs-discussion ### Removed - `ensureSliceBranch()`, `switchToMain()`, `mergeSliceToMain()`, `mergeSliceToMilestone()` @@ -2378,7 +2378,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Multi-milestone readiness flow with per-milestone discussion gate (#377) ### Fixed -- Fix `npx sf-run@latest` failing with `ERR_MODULE_NOT_FOUND: Cannot find package '@gsd/pi-coding-agent'`. The loader now creates workspace package symlinks at runtime before importing, so it works even when `npx` skips postinstall scripts (#380) +- Fix `npx sf-run@latest` failing with `ERR_MODULE_NOT_FOUND: Cannot find package '@sf/pi-coding-agent'`. The loader now creates workspace package symlinks at runtime before importing, so it works even when `npx` skips postinstall scripts (#380) ## [2.10.11] - 2026-03-14 @@ -2395,7 +2395,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Opus 4.6 1M as default model, model selector UX improvements, Discord onboarding (#290) ### Fixed -- Fix broken `npm install` / `npx sf-run@latest` caused by unpublished `@gsd/*` workspace packages leaking into npm dependencies. Workspace cross-references removed from published package metadata; packages resolve via bundled `node_modules/` at runtime (#369) +- Fix broken `npm install` / `npx sf-run@latest` caused by unpublished `@sf/*` workspace packages leaking into npm dependencies. Workspace cross-references removed from published package metadata; packages resolve via bundled `node_modules/` at runtime (#369) - Add pre-publish tarball install validation (`validate-pack`) to CI and publish pipeline, preventing broken packages from reaching npm - Handle empty index after runtime file stripping in squash-merge (#364) - Add retry logic for transient network/auth failures instead of crashing (#365) @@ -2404,7 +2404,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.10.9] - 2026-03-14 ### Added -- Team collaboration: multiple users can work on the same repo without milestone name clashes by checking in `.gsd/` planning artifacts (#338) +- Team collaboration: multiple users can work on the same repo without milestone name clashes by checking in `.sf/` planning artifacts (#338) ### Changed - Execute-task loop detection uses adaptive reconciliation instead of hard-stopping, reducing false positives (#342) @@ -2418,9 +2418,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Secrets skip in auto mode no longer crashes (#352) - Untracked runtime files discarded before branch switch to prevent checkout conflicts (#346) - TUI crash/corruption on code blocks with lines exceeding terminal width (#343) -- Infinite skip loop in `gsd auto` broken by adding roadmap completion check +- Infinite skip loop in `sf auto` broken by adding roadmap completion check - Model ID variant suffix stripped correctly for OAuth Anthropic API calls -- `.gsd/` planning artifacts force-added and `handleAgentEnd` reentrancy guarded (#341) +- `.sf/` planning artifacts force-added and `handleAgentEnd` reentrancy guarded (#341) ## [2.10.8] - 2026-03-14 @@ -2469,7 +2469,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Async background jobs extension for non-blocking task execution (#260) - Multi-credential round-robin with rate-limit fallback across API keys - Bash interceptor to block commands that duplicate dedicated tools (Read, Write, Edit, Grep, Glob) -- `gsd update` subcommand for self-update (#273) +- `sf update` subcommand for self-update (#273) - Task isolation for subagent filesystem safety (#254) - Native Rust streaming JSON parser (#266) - Web search provider selection added to onboarding wizard (#278) @@ -2479,7 +2479,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - `optionalDependencies` in published `sf-run@2.10.4` were still pinned to `2.10.2`, causing users to install the broken engine binaries that 2.10.4 was meant to fix (#276) -- Auto-resolve `.gsd/` planning artifact conflicts during slice merge (#264) +- Auto-resolve `.sf/` planning artifact conflicts during slice merge (#264) - Use version ranges for native engine optional dependencies (#286) - Guard publish against uncommitted version sync changes - Show 'keep current' option in config when already authenticated (#283) @@ -2490,10 +2490,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Native binary distribution — `.node` binaries were missing from the npm tarball, causing startup crashes on all platforms since v2.10.0 -- Native loader resolution chain: tries `@gsd-build/engine-{platform}` npm package first, then local dev build, with clear error messages listing supported platforms +- Native loader resolution chain: tries `@sf-build/engine-{platform}` npm package first, then local dev build, with clear error messages listing supported platforms ### Added -- Per-platform optional dependency packages (`@gsd-build/engine-*`) for macOS (ARM64/x64), Linux (x64/ARM64), and Windows (x64) +- Per-platform optional dependency packages (`@sf-build/engine-*`) for macOS (ARM64/x64), Linux (x64/ARM64), and Windows (x64) - Cross-platform native binary CI build and publish workflow - Version synchronization script for lock-step platform package releases @@ -2502,12 +2502,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Native Rust TTSR regex engine — pre-compiles all stream rule conditions into a single `RegexSet` for one-pass DFA matching instead of O(rules × conditions) JS regex iteration - Native Rust diff engine — fuzzy text matching (`fuzzyFindText`, `normalizeForFuzzyMatch`) and unified diff generation (`generateDiff`) via the `similar` crate, replacing the `diff` npm package -- Native Rust SF file parser — frontmatter parsing, section extraction, batch `.gsd/` directory parsing, and structured roadmap parsing with transparent JS fallback +- Native Rust SF file parser — frontmatter parsing, section extraction, batch `.sf/` directory parsing, and structured roadmap parsing with transparent JS fallback ## [2.10.1] - 2026-03-13 ### Fixed -- `@gsd/native` package ships pre-compiled JavaScript instead of raw TypeScript, fixing startup crashes on Node.js 20, 22, and 24 (#248) +- `@sf/native` package ships pre-compiled JavaScript instead of raw TypeScript, fixing startup crashes on Node.js 20, 22, and 24 (#248) ## [2.10.0] - 2026-03-13 @@ -2549,11 +2549,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - LSP tool — full Language Server Protocol integration with diagnostics, go-to-definition, references, hover, document/workspace symbols, rename, code actions, type definition, and implementation support - `/thinking` slash command for toggling thinking level during sessions -- Interactive wizard mode for `/gsd prefs` with guided configuration +- Interactive wizard mode for `/sf prefs` with guided configuration - Startup update check with 24-hour cache — notifies when a new version is available ### Fixed -- TypeScript type errors across gsd, browser-tools, search-the-web, and misc extension files +- TypeScript type errors across sf, browser-tools, search-the-web, and misc extension files - Milestone ID generation uses max-based approach instead of length+1 (prevents ID collisions) - Non-thinking models handled correctly in `/thinking` command - Auto-mode pauses on provider errors to prevent reassess-roadmap loop @@ -2571,7 +2571,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Provider-aware model resolution for per-phase preferences (respects `provider` field instead of parsing model name prefixes) - Execute-task artifact verification aligned with `deriveState` — adds self-repair for missing artifacts - Research phase infinite loop broken; state synced on stop -- Auto-resolve merge conflicts on `.gsd/` runtime files +- Auto-resolve merge conflicts on `.sf/` runtime files - Auto-switch model after `/login` and `/logout` to prevent API key errors - Anthropic provider detection uses `provider` field instead of model name prefix matching @@ -2585,7 +2585,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Windows NUL redirects sanitized to /dev/null in Git Bash environments ### Changed -- `.claude/` and `.gsd/` directories untracked from repo, `*.tgz` gitignored +- `.claude/` and `.sf/` directories untracked from repo, `*.tgz` gitignored ## [2.8.1] - 2026-03-13 @@ -2601,7 +2601,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Auto-detect headless environment for Playwright browser launch - UAT artifact verified before marking complete-slice done - Prior slices must complete on main before next slice dispatches -- smartStage fallback bypasses runtime exclusions when `.gsd/` is gitignored +- smartStage fallback bypasses runtime exclusions when `.sf/` is gitignored - `/exit` uses graceful shutdown instead of hard kill ## [2.8.0] - 2026-03-13 @@ -2654,7 +2654,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Doctor post-hook no longer preempts `complete-slice` dispatch - `main_branch` preference restored; `runPreMergeCheck` implemented for merge safety - Recovery/retry prompt injection capped to prevent V8 OOM on large sessions -- `.gsd/` excluded from pre-switch auto-commits to prevent squash merge conflicts +- `.sf/` excluded from pre-switch auto-commits to prevent squash merge conflicts ## [2.5.1] - 2026-03-12 @@ -2675,7 +2675,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Native Anthropic web search — Claude models get server-side web search automatically, no Brave API key required - GitService fully wired into codebase — programmatic git operations replace shell-based git commands in prompts - Merge guards prevent slice completion when uncommitted changes or conflicts exist -- Snapshot support for saving and restoring `.gsd/` state +- Snapshot support for saving and restoring `.sf/` state - Auto-push after slice squash-merge to main - Rich commit messages with structured metadata @@ -2706,7 +2706,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Branded clack-based onboarding wizard on first launch — LLM provider selection (OAuth + API key), optional tool API keys, and setup summary (#118) -- `gsd config` subcommand to re-run the setup wizard anytime +- `sf config` subcommand to re-run the setup wizard anytime - Shared `src/logo.ts` module as single source of truth for ASCII banner ### Fixed @@ -2735,12 +2735,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Migration no longer requires ROADMAP.md — milestones inferred from phases/ directory when missing (#93, #90) - Worktree branch safety — proper namespacing and slice branch base selection (#92) - Windows: use `execFile` to avoid single-quote shell issues (#103) -- Broken `read @SF-WORKFLOW.md` references replaced with `/gsd` command (#88) +- Broken `read @SF-WORKFLOW.md` references replaced with `/sf` command (#88) - Google Search extension updated to use `gemini-2.5-flash` (#83) - Duplicate `getCurrentBranch` import in auto.ts (#87) - `formatCost` crash on non-number cost values (#74) - Avoid `sudo` prompts in postinstall script (#73) -- `.gsd/` folder removed from git tracking; consolidated `.gitignore` (#78) +- `.sf/` folder removed from git tracking; consolidated `.gitignore` (#78) - Multiple community-reported bugs across CLI, auto-mode, and extensions ## [2.3.8] - 2026-03-11 @@ -2795,8 +2795,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [0.3.3] - 2026-03-11 ### Added -- `/gsd next` step mode — walk through units one at a time with a wizard between each -- `/gsd` bare command defaults to step mode +- `/sf next` step mode — walk through units one at a time with a wizard between each +- `/sf` bare command defaults to step mode - `/exit` command to kill the SF process immediately - `/clear` as alias for `/new` (new session) - MCPorter extension for lazy on-demand MCP server integration @@ -2814,7 +2814,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Pi extensions loaded from `~/.pi/agent/extensions/` (#51) ### Removed -- `/gsd-run` command (replaced by `/gsd` and `/gsd next`) +- `/sf-run` command (replaced by `/sf` and `/sf next`) ## [0.3.1] - 2026-03-11 @@ -2825,7 +2825,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Managed tools bootstrap and gh auth - Session list scoped to current working directory - Bash/bg_shell hang and kill issues on Windows (#40) -- `/gsd-run` hardcoded `~/.pi/` path (#38) +- `/sf-run` hardcoded `~/.pi/` path (#38) - Windows backspace in masked input + custom browser path support (#36, #34) ### Changed @@ -2835,7 +2835,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `/worktree` (`/wt`) — git worktree lifecycle management (#31) -- `/gsd migrate` — `.planning` to `.gsd` migration tool (#28) +- `/sf migrate` — `.planning` to `.sf` migration tool (#28) ### Fixed - Skipped API keys now persist so wizard doesn't repeat on every launch (#27) @@ -2872,7 +2872,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Postinstall banner with version and next-step hint ### Fixed -- All `.pi/` paths updated to `.gsd/` +- All `.pi/` paths updated to `.sf/` - Default model matching by `id.includes('sonnet')` for dated API IDs - Circular sf-run self-dependency removed - Pi SDK version check suppressed @@ -2884,134 +2884,134 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - GitHub extension tool suite with confirmation gate - Bundled skills: frontend-design, swiftui, debug-like-expert - Skills trigger table in system prompt -- Resource loader syncs bundled skills to `~/.gsd/agent/skills/` +- Resource loader syncs bundled skills to `~/.sf/agent/skills/` ### Fixed -- `~/.gsd/agent/` paths in prompt templates instead of `~/.pi/agent/` (#10) +- `~/.sf/agent/` paths in prompt templates instead of `~/.pi/agent/` (#10) - Guard against re-injecting discuss prompt when session already in flight ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.74.0...HEAD -[2.74.0]: https://github.com/gsd-build/gsd-2/compare/v2.73.1...v2.74.0 -[2.73.1]: https://github.com/gsd-build/gsd-2/compare/v2.73.0...v2.73.1 -[2.73.0]: https://github.com/gsd-build/gsd-2/compare/v2.72.0...v2.73.0 -[2.72.0]: https://github.com/gsd-build/gsd-2/compare/v2.71.0...v2.72.0 -[2.71.0]: https://github.com/gsd-build/gsd-2/compare/v2.70.1...v2.71.0 -[2.70.1]: https://github.com/gsd-build/gsd-2/compare/v2.70.0...v2.70.1 -[2.70.0]: https://github.com/gsd-build/gsd-2/compare/v2.69.0...v2.70.0 -[2.69.0]: https://github.com/gsd-build/gsd-2/compare/v2.68.1...v2.69.0 -[2.68.1]: https://github.com/gsd-build/gsd-2/compare/v2.68.0...v2.68.1 -[2.68.0]: https://github.com/gsd-build/gsd-2/compare/v2.67.0...v2.68.0 -[2.67.0]: https://github.com/gsd-build/gsd-2/compare/v2.66.1...v2.67.0 -[2.66.1]: https://github.com/gsd-build/gsd-2/compare/v2.66.0...v2.66.1 -[2.66.0]: https://github.com/gsd-build/gsd-2/compare/v2.65.0...v2.66.0 -[2.65.0]: https://github.com/gsd-build/gsd-2/compare/v2.64.0...v2.65.0 -[2.64.0]: https://github.com/gsd-build/gsd-2/compare/v2.63.0...v2.64.0 -[2.63.0]: https://github.com/gsd-build/gsd-2/compare/v2.62.1...v2.63.0 -[2.62.1]: https://github.com/gsd-build/gsd-2/compare/v2.62.0...v2.62.1 -[2.62.0]: https://github.com/gsd-build/gsd-2/compare/v2.61.0...v2.62.0 -[2.61.0]: https://github.com/gsd-build/gsd-2/compare/v2.60.0...v2.61.0 -[2.60.0]: https://github.com/gsd-build/gsd-2/compare/v2.59.0...v2.60.0 -[2.59.0]: https://github.com/gsd-build/gsd-2/compare/v2.58.0...v2.59.0 -[2.58.0]: https://github.com/gsd-build/gsd-2/compare/v2.57.0...v2.58.0 -[2.57.0]: https://github.com/gsd-build/gsd-2/compare/v2.56.0...v2.57.0 -[2.56.0]: https://github.com/gsd-build/gsd-2/compare/v2.55.0...v2.56.0 -[2.55.0]: https://github.com/gsd-build/gsd-2/compare/v2.54.0...v2.55.0 -[2.54.0]: https://github.com/gsd-build/gsd-2/compare/v2.53.0...v2.54.0 -[2.53.0]: https://github.com/gsd-build/gsd-2/compare/v2.52.0...v2.53.0 -[2.52.0]: https://github.com/gsd-build/gsd-2/compare/v2.51.0...v2.52.0 -[2.51.0]: https://github.com/gsd-build/gsd-2/compare/v2.50.0...v2.51.0 -[2.50.0]: https://github.com/gsd-build/gsd-2/compare/v2.49.0...v2.50.0 -[2.49.0]: https://github.com/gsd-build/gsd-2/compare/v2.48.0...v2.49.0 -[2.48.0]: https://github.com/gsd-build/gsd-2/compare/v2.47.0...v2.48.0 -[2.47.0]: https://github.com/gsd-build/gsd-2/compare/v2.46.1...v2.47.0 -[2.46.1]: https://github.com/gsd-build/gsd-2/compare/v2.46.0...v2.46.1 -[2.46.0]: https://github.com/gsd-build/gsd-2/compare/v2.45.0...v2.46.0 -[2.45.0]: https://github.com/gsd-build/gsd-2/compare/v2.44.0...v2.45.0 -[2.44.0]: https://github.com/gsd-build/gsd-2/compare/v2.43.0...v2.44.0 -[2.43.0]: https://github.com/gsd-build/gsd-2/compare/v2.42.0...v2.43.0 -[2.42.0]: https://github.com/gsd-build/gsd-2/compare/v2.41.0...v2.42.0 -[2.41.0]: https://github.com/gsd-build/gsd-2/compare/v2.40.0...v2.41.0 -[2.40.0]: https://github.com/gsd-build/gsd-2/compare/v2.39.0...v2.40.0 -[2.39.0]: https://github.com/gsd-build/gsd-2/compare/v2.38.0...v2.39.0 -[2.38.0]: https://github.com/gsd-build/gsd-2/compare/v2.37.1...v2.38.0 -[2.37.1]: https://github.com/gsd-build/gsd-2/compare/v2.37.0...v2.37.1 -[2.37.0]: https://github.com/gsd-build/gsd-2/compare/v2.36.0...v2.37.0 -[2.36.0]: https://github.com/gsd-build/gsd-2/compare/v2.35.0...v2.36.0 -[2.35.0]: https://github.com/gsd-build/gsd-2/compare/v2.34.0...v2.35.0 -[2.34.0]: https://github.com/gsd-build/gsd-2/compare/v2.33.1...v2.34.0 -[2.33.1]: https://github.com/gsd-build/gsd-2/compare/v2.33.0...v2.33.1 -[2.33.0]: https://github.com/gsd-build/gsd-2/compare/v2.32.0...v2.33.0 -[2.32.0]: https://github.com/gsd-build/gsd-2/compare/v2.31.2...v2.32.0 -[2.31.2]: https://github.com/gsd-build/gsd-2/compare/v2.31.1...v2.31.2 -[2.31.1]: https://github.com/gsd-build/gsd-2/compare/v2.31.0...v2.31.1 -[2.31.0]: https://github.com/gsd-build/gsd-2/compare/v2.30.0...v2.31.0 -[2.30.0]: https://github.com/gsd-build/gsd-2/compare/v2.29.0...v2.30.0 -[2.29.0]: https://github.com/gsd-build/gsd-2/compare/v2.28.0...v2.29.0 -[2.28.0]: https://github.com/gsd-build/gsd-2/compare/v2.27.0...v2.28.0 -[2.27.0]: https://github.com/gsd-build/gsd-2/compare/v2.26.0...v2.27.0 -[2.26.0]: https://github.com/gsd-build/gsd-2/compare/v2.25.0...v2.26.0 -[2.25.0]: https://github.com/gsd-build/gsd-2/releases/tag/v2.25.0 -[2.24.0]: https://github.com/gsd-build/gsd-2/compare/v2.23.0...v2.24.0 -[2.23.0]: https://github.com/gsd-build/gsd-2/compare/v2.22.0...v2.23.0 -[2.21.0]: https://github.com/gsd-build/gsd-2/compare/v2.20.0...v2.21.0 -[2.19.0]: https://github.com/gsd-build/gsd-2/compare/v2.18.0...v2.19.0 -[2.18.0]: https://github.com/gsd-build/gsd-2/compare/v2.17.0...v2.18.0 -[2.17.0]: https://github.com/gsd-build/gsd-2/compare/v2.16.0...v2.17.0 -[2.16.0]: https://github.com/gsd-build/gsd-2/compare/v2.15.1...v2.16.0 -[2.15.1]: https://github.com/gsd-build/gsd-2/releases/tag/v2.15.1 -[2.15.0]: https://github.com/gsd-build/gsd-2/compare/v2.14.4...v2.15.0 -[2.14.4]: https://github.com/gsd-build/gsd-2/compare/v2.14.3...v2.14.4 -[2.14.3]: https://github.com/gsd-build/gsd-2/compare/v2.14.2...v2.14.3 -[2.14.2]: https://github.com/gsd-build/gsd-2/compare/v2.14.1...v2.14.2 -[2.14.1]: https://github.com/gsd-build/gsd-2/compare/v2.14.0...v2.14.1 -[2.14.0]: https://github.com/gsd-build/gsd-2/compare/v2.13.1...v2.14.0 -[2.13.1]: https://github.com/gsd-build/gsd-2/compare/v2.13.0...v2.13.1 -[2.13.0]: https://github.com/gsd-build/gsd-2/compare/v2.12.0...v2.13.0 -[2.12.0]: https://github.com/gsd-build/gsd-2/compare/v2.11.1...v2.12.0 -[2.11.1]: https://github.com/gsd-build/gsd-2/compare/v2.11.0...v2.11.1 -[2.11.0]: https://github.com/gsd-build/gsd-2/compare/v2.10.12...v2.11.0 -[2.10.12]: https://github.com/gsd-build/gsd-2/compare/v2.10.11...v2.10.12 -[2.10.11]: https://github.com/gsd-build/gsd-2/compare/v2.10.10...v2.10.11 -[2.10.10]: https://github.com/gsd-build/gsd-2/compare/v2.10.9...v2.10.10 -[2.10.9]: https://github.com/gsd-build/gsd-2/compare/v2.10.8...v2.10.9 -[2.10.8]: https://github.com/gsd-build/gsd-2/compare/v2.10.7...v2.10.8 -[2.10.7]: https://github.com/gsd-build/gsd-2/compare/v2.10.6...v2.10.7 -[2.10.6]: https://github.com/gsd-build/gsd-2/compare/v2.10.5...v2.10.6 -[2.10.5]: https://github.com/gsd-build/gsd-2/compare/v2.10.4...v2.10.5 -[2.10.4]: https://github.com/gsd-build/gsd-2/compare/v2.10.2...v2.10.4 -[2.10.2]: https://github.com/gsd-build/gsd-2/compare/v2.10.1...v2.10.2 -[2.10.1]: https://github.com/gsd-build/gsd-2/compare/v2.10.0...v2.10.1 -[2.10.0]: https://github.com/gsd-build/gsd-2/compare/v2.9.0...v2.10.0 -[2.9.0]: https://github.com/gsd-build/gsd-2/compare/v2.8.3...v2.9.0 -[2.8.3]: https://github.com/gsd-build/gsd-2/compare/v2.8.2...v2.8.3 -[2.8.2]: https://github.com/gsd-build/gsd-2/compare/v2.8.1...v2.8.2 -[2.8.1]: https://github.com/gsd-build/gsd-2/compare/v2.8.0...v2.8.1 -[2.8.0]: https://github.com/gsd-build/gsd-2/compare/v2.7.1...v2.8.0 -[2.7.1]: https://github.com/gsd-build/gsd-2/compare/v2.7.0...v2.7.1 -[2.7.0]: https://github.com/gsd-build/gsd-2/compare/v2.6.0...v2.7.0 -[2.6.0]: https://github.com/gsd-build/gsd-2/compare/v2.5.1...v2.6.0 -[2.20.0]: https://github.com/gsd-build/gsd-2/releases/tag/v2.20.0 -[2.22.0]: https://github.com/gsd-build/gsd-2/releases/tag/v2.22.0 -[2.5.1]: https://github.com/gsd-build/gsd-2/compare/v2.5.0...v2.5.1 -[2.5.0]: https://github.com/gsd-build/gsd-2/compare/v2.4.0...v2.5.0 -[2.4.0]: https://github.com/gsd-build/gsd-2/compare/v2.3.11...v2.4.0 -[2.3.11]: https://github.com/gsd-build/gsd-2/compare/v2.3.10...v2.3.11 -[2.3.10]: https://github.com/gsd-build/gsd-2/compare/v2.3.9...v2.3.10 -[2.3.9]: https://github.com/gsd-build/gsd-2/compare/v2.3.8...v2.3.9 -[2.3.8]: https://github.com/gsd-build/gsd-2/compare/v2.3.7...v2.3.8 -[2.3.7]: https://github.com/gsd-build/gsd-2/compare/v2.3.6...v2.3.7 -[2.3.6]: https://github.com/gsd-build/gsd-2/compare/v2.3.5...v2.3.6 -[2.3.5]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...v2.3.5 -[2.3.4]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...v2.3.4 -[0.3.3]: https://github.com/gsd-build/gsd-2/compare/v0.3.1...v0.3.3 -[0.3.1]: https://github.com/gsd-build/gsd-2/compare/v0.3.0...v0.3.1 -[0.3.0]: https://github.com/gsd-build/gsd-2/compare/v0.2.9...v0.3.0 -[0.2.9]: https://github.com/gsd-build/gsd-2/compare/v0.2.8...v0.2.9 -[0.2.8]: https://github.com/gsd-build/gsd-2/compare/v0.2.6...v0.2.8 -[0.2.6]: https://github.com/gsd-build/gsd-2/compare/v0.2.5...v0.2.6 -[0.2.5]: https://github.com/gsd-build/gsd-2/compare/v0.2.4...v0.2.5 -[0.2.4]: https://github.com/gsd-build/gsd-2/compare/v0.1.6...v0.2.4 -[0.1.6]: https://github.com/gsd-build/gsd-2/releases/tag/v0.1.6 +[Unreleased]: https://github.com/sf-build/sf-2/compare/v2.74.0...HEAD +[2.74.0]: https://github.com/sf-build/sf-2/compare/v2.73.1...v2.74.0 +[2.73.1]: https://github.com/sf-build/sf-2/compare/v2.73.0...v2.73.1 +[2.73.0]: https://github.com/sf-build/sf-2/compare/v2.72.0...v2.73.0 +[2.72.0]: https://github.com/sf-build/sf-2/compare/v2.71.0...v2.72.0 +[2.71.0]: https://github.com/sf-build/sf-2/compare/v2.70.1...v2.71.0 +[2.70.1]: https://github.com/sf-build/sf-2/compare/v2.70.0...v2.70.1 +[2.70.0]: https://github.com/sf-build/sf-2/compare/v2.69.0...v2.70.0 +[2.69.0]: https://github.com/sf-build/sf-2/compare/v2.68.1...v2.69.0 +[2.68.1]: https://github.com/sf-build/sf-2/compare/v2.68.0...v2.68.1 +[2.68.0]: https://github.com/sf-build/sf-2/compare/v2.67.0...v2.68.0 +[2.67.0]: https://github.com/sf-build/sf-2/compare/v2.66.1...v2.67.0 +[2.66.1]: https://github.com/sf-build/sf-2/compare/v2.66.0...v2.66.1 +[2.66.0]: https://github.com/sf-build/sf-2/compare/v2.65.0...v2.66.0 +[2.65.0]: https://github.com/sf-build/sf-2/compare/v2.64.0...v2.65.0 +[2.64.0]: https://github.com/sf-build/sf-2/compare/v2.63.0...v2.64.0 +[2.63.0]: https://github.com/sf-build/sf-2/compare/v2.62.1...v2.63.0 +[2.62.1]: https://github.com/sf-build/sf-2/compare/v2.62.0...v2.62.1 +[2.62.0]: https://github.com/sf-build/sf-2/compare/v2.61.0...v2.62.0 +[2.61.0]: https://github.com/sf-build/sf-2/compare/v2.60.0...v2.61.0 +[2.60.0]: https://github.com/sf-build/sf-2/compare/v2.59.0...v2.60.0 +[2.59.0]: https://github.com/sf-build/sf-2/compare/v2.58.0...v2.59.0 +[2.58.0]: https://github.com/sf-build/sf-2/compare/v2.57.0...v2.58.0 +[2.57.0]: https://github.com/sf-build/sf-2/compare/v2.56.0...v2.57.0 +[2.56.0]: https://github.com/sf-build/sf-2/compare/v2.55.0...v2.56.0 +[2.55.0]: https://github.com/sf-build/sf-2/compare/v2.54.0...v2.55.0 +[2.54.0]: https://github.com/sf-build/sf-2/compare/v2.53.0...v2.54.0 +[2.53.0]: https://github.com/sf-build/sf-2/compare/v2.52.0...v2.53.0 +[2.52.0]: https://github.com/sf-build/sf-2/compare/v2.51.0...v2.52.0 +[2.51.0]: https://github.com/sf-build/sf-2/compare/v2.50.0...v2.51.0 +[2.50.0]: https://github.com/sf-build/sf-2/compare/v2.49.0...v2.50.0 +[2.49.0]: https://github.com/sf-build/sf-2/compare/v2.48.0...v2.49.0 +[2.48.0]: https://github.com/sf-build/sf-2/compare/v2.47.0...v2.48.0 +[2.47.0]: https://github.com/sf-build/sf-2/compare/v2.46.1...v2.47.0 +[2.46.1]: https://github.com/sf-build/sf-2/compare/v2.46.0...v2.46.1 +[2.46.0]: https://github.com/sf-build/sf-2/compare/v2.45.0...v2.46.0 +[2.45.0]: https://github.com/sf-build/sf-2/compare/v2.44.0...v2.45.0 +[2.44.0]: https://github.com/sf-build/sf-2/compare/v2.43.0...v2.44.0 +[2.43.0]: https://github.com/sf-build/sf-2/compare/v2.42.0...v2.43.0 +[2.42.0]: https://github.com/sf-build/sf-2/compare/v2.41.0...v2.42.0 +[2.41.0]: https://github.com/sf-build/sf-2/compare/v2.40.0...v2.41.0 +[2.40.0]: https://github.com/sf-build/sf-2/compare/v2.39.0...v2.40.0 +[2.39.0]: https://github.com/sf-build/sf-2/compare/v2.38.0...v2.39.0 +[2.38.0]: https://github.com/sf-build/sf-2/compare/v2.37.1...v2.38.0 +[2.37.1]: https://github.com/sf-build/sf-2/compare/v2.37.0...v2.37.1 +[2.37.0]: https://github.com/sf-build/sf-2/compare/v2.36.0...v2.37.0 +[2.36.0]: https://github.com/sf-build/sf-2/compare/v2.35.0...v2.36.0 +[2.35.0]: https://github.com/sf-build/sf-2/compare/v2.34.0...v2.35.0 +[2.34.0]: https://github.com/sf-build/sf-2/compare/v2.33.1...v2.34.0 +[2.33.1]: https://github.com/sf-build/sf-2/compare/v2.33.0...v2.33.1 +[2.33.0]: https://github.com/sf-build/sf-2/compare/v2.32.0...v2.33.0 +[2.32.0]: https://github.com/sf-build/sf-2/compare/v2.31.2...v2.32.0 +[2.31.2]: https://github.com/sf-build/sf-2/compare/v2.31.1...v2.31.2 +[2.31.1]: https://github.com/sf-build/sf-2/compare/v2.31.0...v2.31.1 +[2.31.0]: https://github.com/sf-build/sf-2/compare/v2.30.0...v2.31.0 +[2.30.0]: https://github.com/sf-build/sf-2/compare/v2.29.0...v2.30.0 +[2.29.0]: https://github.com/sf-build/sf-2/compare/v2.28.0...v2.29.0 +[2.28.0]: https://github.com/sf-build/sf-2/compare/v2.27.0...v2.28.0 +[2.27.0]: https://github.com/sf-build/sf-2/compare/v2.26.0...v2.27.0 +[2.26.0]: https://github.com/sf-build/sf-2/compare/v2.25.0...v2.26.0 +[2.25.0]: https://github.com/sf-build/sf-2/releases/tag/v2.25.0 +[2.24.0]: https://github.com/sf-build/sf-2/compare/v2.23.0...v2.24.0 +[2.23.0]: https://github.com/sf-build/sf-2/compare/v2.22.0...v2.23.0 +[2.21.0]: https://github.com/sf-build/sf-2/compare/v2.20.0...v2.21.0 +[2.19.0]: https://github.com/sf-build/sf-2/compare/v2.18.0...v2.19.0 +[2.18.0]: https://github.com/sf-build/sf-2/compare/v2.17.0...v2.18.0 +[2.17.0]: https://github.com/sf-build/sf-2/compare/v2.16.0...v2.17.0 +[2.16.0]: https://github.com/sf-build/sf-2/compare/v2.15.1...v2.16.0 +[2.15.1]: https://github.com/sf-build/sf-2/releases/tag/v2.15.1 +[2.15.0]: https://github.com/sf-build/sf-2/compare/v2.14.4...v2.15.0 +[2.14.4]: https://github.com/sf-build/sf-2/compare/v2.14.3...v2.14.4 +[2.14.3]: https://github.com/sf-build/sf-2/compare/v2.14.2...v2.14.3 +[2.14.2]: https://github.com/sf-build/sf-2/compare/v2.14.1...v2.14.2 +[2.14.1]: https://github.com/sf-build/sf-2/compare/v2.14.0...v2.14.1 +[2.14.0]: https://github.com/sf-build/sf-2/compare/v2.13.1...v2.14.0 +[2.13.1]: https://github.com/sf-build/sf-2/compare/v2.13.0...v2.13.1 +[2.13.0]: https://github.com/sf-build/sf-2/compare/v2.12.0...v2.13.0 +[2.12.0]: https://github.com/sf-build/sf-2/compare/v2.11.1...v2.12.0 +[2.11.1]: https://github.com/sf-build/sf-2/compare/v2.11.0...v2.11.1 +[2.11.0]: https://github.com/sf-build/sf-2/compare/v2.10.12...v2.11.0 +[2.10.12]: https://github.com/sf-build/sf-2/compare/v2.10.11...v2.10.12 +[2.10.11]: https://github.com/sf-build/sf-2/compare/v2.10.10...v2.10.11 +[2.10.10]: https://github.com/sf-build/sf-2/compare/v2.10.9...v2.10.10 +[2.10.9]: https://github.com/sf-build/sf-2/compare/v2.10.8...v2.10.9 +[2.10.8]: https://github.com/sf-build/sf-2/compare/v2.10.7...v2.10.8 +[2.10.7]: https://github.com/sf-build/sf-2/compare/v2.10.6...v2.10.7 +[2.10.6]: https://github.com/sf-build/sf-2/compare/v2.10.5...v2.10.6 +[2.10.5]: https://github.com/sf-build/sf-2/compare/v2.10.4...v2.10.5 +[2.10.4]: https://github.com/sf-build/sf-2/compare/v2.10.2...v2.10.4 +[2.10.2]: https://github.com/sf-build/sf-2/compare/v2.10.1...v2.10.2 +[2.10.1]: https://github.com/sf-build/sf-2/compare/v2.10.0...v2.10.1 +[2.10.0]: https://github.com/sf-build/sf-2/compare/v2.9.0...v2.10.0 +[2.9.0]: https://github.com/sf-build/sf-2/compare/v2.8.3...v2.9.0 +[2.8.3]: https://github.com/sf-build/sf-2/compare/v2.8.2...v2.8.3 +[2.8.2]: https://github.com/sf-build/sf-2/compare/v2.8.1...v2.8.2 +[2.8.1]: https://github.com/sf-build/sf-2/compare/v2.8.0...v2.8.1 +[2.8.0]: https://github.com/sf-build/sf-2/compare/v2.7.1...v2.8.0 +[2.7.1]: https://github.com/sf-build/sf-2/compare/v2.7.0...v2.7.1 +[2.7.0]: https://github.com/sf-build/sf-2/compare/v2.6.0...v2.7.0 +[2.6.0]: https://github.com/sf-build/sf-2/compare/v2.5.1...v2.6.0 +[2.20.0]: https://github.com/sf-build/sf-2/releases/tag/v2.20.0 +[2.22.0]: https://github.com/sf-build/sf-2/releases/tag/v2.22.0 +[2.5.1]: https://github.com/sf-build/sf-2/compare/v2.5.0...v2.5.1 +[2.5.0]: https://github.com/sf-build/sf-2/compare/v2.4.0...v2.5.0 +[2.4.0]: https://github.com/sf-build/sf-2/compare/v2.3.11...v2.4.0 +[2.3.11]: https://github.com/sf-build/sf-2/compare/v2.3.10...v2.3.11 +[2.3.10]: https://github.com/sf-build/sf-2/compare/v2.3.9...v2.3.10 +[2.3.9]: https://github.com/sf-build/sf-2/compare/v2.3.8...v2.3.9 +[2.3.8]: https://github.com/sf-build/sf-2/compare/v2.3.7...v2.3.8 +[2.3.7]: https://github.com/sf-build/sf-2/compare/v2.3.6...v2.3.7 +[2.3.6]: https://github.com/sf-build/sf-2/compare/v2.3.5...v2.3.6 +[2.3.5]: https://github.com/sf-build/sf-2/compare/v2.3.4...v2.3.5 +[2.3.4]: https://github.com/sf-build/sf-2/compare/v0.3.3...v2.3.4 +[0.3.3]: https://github.com/sf-build/sf-2/compare/v0.3.1...v0.3.3 +[0.3.1]: https://github.com/sf-build/sf-2/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/sf-build/sf-2/compare/v0.2.9...v0.3.0 +[0.2.9]: https://github.com/sf-build/sf-2/compare/v0.2.8...v0.2.9 +[0.2.8]: https://github.com/sf-build/sf-2/compare/v0.2.6...v0.2.8 +[0.2.6]: https://github.com/sf-build/sf-2/compare/v0.2.5...v0.2.6 +[0.2.5]: https://github.com/sf-build/sf-2/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/sf-build/sf-2/compare/v0.1.6...v0.2.4 +[0.1.6]: https://github.com/sf-build/sf-2/releases/tag/v0.1.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83a48d118..f11e3a04d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ git rebase origin/main SF uses worktree-based isolation for multi-developer work. If you're contributing with SF running, enable team mode in your project preferences: ```yaml -# .gsd/PREFERENCES.md +# .sf/PREFERENCES.md --- version: 1 mode: team @@ -147,7 +147,7 @@ The codebase is organized into these areas. All are open to contributions: | Agent core | `packages/pi-agent-core` | Agent orchestration — RFC required for changes | | Coding agent | `packages/pi-coding-agent` | The main coding agent | | MCP server | `packages/mcp-server` | Project state tools and MCP protocol | -| SF extension | `src/resources/extensions/gsd/` | SF workflow — RFC required for auto-mode | +| SF extension | `src/resources/extensions/sf/` | SF workflow — RFC required for auto-mode | | Other extensions | `src/resources/extensions/` | Browser, search, voice, MCP client, etc. | | Native engine | `native/` | Rust N-API modules (grep, git, AST, etc.) | | VS Code extension | `vscode-extension/` | Chat participant, sidebar, RPC integration | diff --git a/README.md b/README.md index cafeeb987..cf1eff8bc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # SF -**The evolution of [Singularity Forge](https://github.com/gsd-build/get-shit-done) — now a real coding agent.** +**The evolution of [Singularity Forge](https://github.com/sf-build/get-shit-done) — now a real coding agent.** [![npm version](https://img.shields.io/npm/v/sf-run?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/sf-run) [![npm downloads](https://img.shields.io/npm/dm/sf-run?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/sf-run) -[![GitHub stars](https://img.shields.io/github/stars/gsd-build/SF?style=for-the-badge&logo=github&color=181717)](https://github.com/gsd-build/SF) +[![GitHub stars](https://img.shields.io/github/stars/sf-build/SF?style=for-the-badge&logo=github&color=181717)](https://github.com/sf-build/SF) [![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/invite/nKXTsAcmbT) [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE) [![$SF Token](https://img.shields.io/badge/$SF-Dexscreener-1C1C1C?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iIzAwRkYwMCIvPjwvc3ZnPg==&logoColor=00FF00)](https://dexscreener.com/solana/dwudwjvan7bzkw9zwlbyv6kspdlvhwzrqy6ebk8xzxkv) @@ -44,7 +44,7 @@ One command. Walk away. Come back to a built project with clean git history. - **Unconfigured models blocked** — models without a configured provider are filtered from selection surfaces, preventing dispatch failures. - **Provider readiness required** — saved default model selection now verifies the provider is ready before accepting it. -- **Session override honored** — `/gsd model` selection persists as a session override across all dispatch phases. +- **Session override honored** — `/sf model` selection persists as a session override across all dispatch phases. - **Minimal context guard** — model override logic is skipped in minimal command contexts where it doesn't apply. ### Auto-Mode Resilience @@ -101,7 +101,7 @@ See the full [Changelog](./CHANGELOG.md) for details on every release. - **Discord bot & daemon** — dedicated daemon package, Discord bot, and headless text mode with tool calls - **Capability-aware model routing (ADR-004)** — capability scoring, `before_model_select` hook, and task metadata extraction - **VS Code sidebar redesign** — SCM provider, checkpoints, diagnostics panel, activity feed, workflow controls, session forking -- **`/gsd parallel watch`** — native TUI overlay for real-time worker monitoring +- **`/sf parallel watch`** — native TUI overlay for real-time worker monitoring - **Codebase map** — automatic codebase map injection for fresh agent contexts - **`--resume` flag** — resume previous sessions from the CLI - **Concurrent invocation guard** — prevents overlapping auto-mode runs @@ -138,7 +138,7 @@ Full documentation is in the [`docs/`](./docs/) directory: - **[Remote Questions](./docs/user-docs/remote-questions.md)** — route decisions to Slack or Discord when human input is needed - **[Dynamic Model Routing](./docs/user-docs/dynamic-model-routing.md)** — complexity-based model selection and budget pressure - **[Web Interface](./docs/user-docs/web-interface.md)** — browser-based project management and real-time progress -- **[Migration from v1](./docs/user-docs/migration.md)** — `.planning` → `.gsd` migration +- **[Migration from v1](./docs/user-docs/migration.md)** — `.planning` → `.sf` migration - **[Docker Sandbox](./docker/README.md)** — run SF auto mode in an isolated Docker container ### Developer Docs @@ -165,7 +165,7 @@ SF v2 solves all of these because it's not a prompt framework anymore — it's a | -------------------- | ---------------------------- | ------------------------------------------------------- | | Runtime | Claude Code slash commands | Standalone CLI via Pi SDK | | Context management | Hope the LLM doesn't fill up | Fresh session per task, programmatic | -| Auto mode | LLM self-loop | State machine reading `.gsd/` files | +| Auto mode | LLM self-loop | State machine reading `.sf/` files | | Crash recovery | None | Lock files + session forensics | | Git strategy | LLM writes git commands | Worktree isolation, sequential commits, squash merge | | Cost tracking | None | Per-unit token/cost ledger with dashboard | @@ -182,14 +182,14 @@ SF v2 solves all of these because it's not a prompt framework anymore — it's a > **Note:** Migration works best with a `ROADMAP.md` file for milestone structure. Without one, milestones are inferred from the `phases/` directory. -If you have projects with `.planning` directories from the original Singularity Forge, you can migrate them to SF's `.gsd` format: +If you have projects with `.planning` directories from the original Singularity Forge, you can migrate them to SF's `.sf` format: ```bash # From within the project directory -/gsd migrate +/sf migrate # Or specify a path -/gsd migrate ~/projects/my-old-project +/sf migrate ~/projects/my-old-project ``` The migration tool: @@ -229,15 +229,15 @@ Plan (with integrated research) → Execute (per task) → Complete → Reassess **Plan** scouts the codebase, researches relevant docs, and decomposes the slice into tasks with must-haves (mechanically verifiable outcomes). **Execute** runs each task in a fresh context window with only the relevant files pre-loaded — then runs configured verification commands (lint, test, etc.) with auto-fix retries. **Complete** writes the summary, UAT script, marks the roadmap, and commits with meaningful messages derived from task summaries. **Reassess** checks if the roadmap still makes sense given what was learned. **Validate Milestone** runs a reconciliation gate after all slices complete — comparing roadmap success criteria against actual results before sealing the milestone. -### `/gsd auto` — The Main Event +### `/sf auto` — The Main Event This is what makes SF different. Run it, walk away, come back to built software. ``` -/gsd auto +/sf auto ``` -Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. +Auto mode is a state machine driven by files on disk. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. **What happens under the hood:** @@ -247,7 +247,7 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 3. **Git isolation** — When `git.isolation` is set to `worktree` or `branch`, each milestone runs on its own `milestone/` branch (in a worktree or in-place). All slice work commits sequentially — no branch switching, no merge conflicts. When the milestone completes, it's squash-merged to main as one clean commit. The default is `none` (work on the current branch), configurable via preferences. -4. **Crash recovery** — A lock file tracks the current unit. If the session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. Parallel orchestrator state is persisted to disk with PID liveness detection, so multi-worker sessions survive crashes too. In headless mode, crashes trigger automatic restart with exponential backoff (default 3 attempts). +4. **Crash recovery** — A lock file tracks the current unit. If the session dies, the next `/sf auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. Parallel orchestrator state is persisted to disk with PID liveness detection, so multi-worker sessions survive crashes too. In headless mode, crashes trigger automatic restart with exponential backoff (default 3 attempts). 5. **Provider error recovery** — Transient provider errors (rate limits, 500/503 server errors, overloaded) auto-resume after a delay. Permanent errors (auth, billing) pause for manual review. The model fallback chain retries transient network errors before switching models. @@ -263,18 +263,18 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 11. **Milestone validation** — After all slices complete, a `validate-milestone` gate compares roadmap success criteria against actual results before sealing the milestone. -12. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/gsd auto` to resume from disk state. +12. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/sf auto` to resume from disk state. -### `/gsd` and `/gsd next` — Step Mode +### `/sf` and `/sf next` — Step Mode -By default, `/gsd` runs in **step mode**: the same state machine as auto mode, but it pauses between units with a wizard showing what completed and what's next. You advance one step at a time, review the output, and continue when ready. +By default, `/sf` runs in **step mode**: the same state machine as auto mode, but it pauses between units with a wizard showing what completed and what's next. You advance one step at a time, review the output, and continue when ready. -- **No `.gsd/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences. +- **No `.sf/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences. - **Milestone exists, no roadmap** → Discuss or research the milestone. - **Roadmap exists, slices pending** → Plan the next slice, execute one task, or switch to auto. - **Mid-task** → Resume from where you left off. -`/gsd next` is an explicit alias for step mode. You can switch from step → auto mid-session via the wizard. +`/sf next` is an explicit alias for step mode. You can switch from step → auto mid-session via the wizard. Step mode is the on-ramp. Auto mode is the highway. @@ -293,7 +293,7 @@ npm install -g sf-run First, choose your LLM provider: ```bash -gsd +sf /login ``` @@ -310,14 +310,14 @@ SF auto-selects a default model after login. To switch models later: Open a terminal in your project and run: ```bash -gsd +sf ``` SF opens an interactive agent session. From there, you have two ways to work: -**`/gsd` — step mode.** Type `/gsd` and SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as auto mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step. +**`/sf` — step mode.** Type `/sf` and SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as auto mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step. -**`/gsd auto` — autonomous mode.** Type `/gsd auto` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. +**`/sf auto` — autonomous mode.** Type `/sf auto` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. ### Two terminals, one project @@ -326,75 +326,75 @@ The real workflow: run auto mode in one terminal, steer from another. **Terminal 1 — let it build** ```bash -gsd -/gsd auto +sf +/sf auto ``` **Terminal 2 — steer while it works** ```bash -gsd -/gsd discuss # talk through architecture decisions -/gsd status # check progress -/gsd queue # queue the next milestone +sf +/sf discuss # talk through architecture decisions +/sf status # check progress +/sf queue # queue the next milestone ``` -Both terminals read and write the same `.gsd/` files on disk. Your decisions in terminal 2 are picked up automatically at the next phase boundary — no need to stop auto mode. +Both terminals read and write the same `.sf/` files on disk. Your decisions in terminal 2 are picked up automatically at the next phase boundary — no need to stop auto mode. ### Headless mode — CI and scripts -`gsd headless` runs any `/gsd` command without a TUI. Designed for CI pipelines, cron jobs, and scripted automation. +`sf headless` runs any `/sf` command without a TUI. Designed for CI pipelines, cron jobs, and scripted automation. ```bash # Run auto mode in CI -gsd headless --timeout 600000 +sf headless --timeout 600000 # Create and execute a milestone end-to-end -gsd headless new-milestone --context spec.md --auto +sf headless new-milestone --context spec.md --auto # One unit at a time (cron-friendly) -gsd headless next +sf headless next # Instant JSON snapshot (no LLM, ~50ms) -gsd headless query +sf headless query # Force a specific pipeline phase -gsd headless dispatch plan +sf headless dispatch plan ``` -Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Use `gsd headless query` for instant, machine-readable state inspection — returns phase, next dispatch preview, and parallel worker costs as a single JSON object without spawning an LLM session. Pair with [remote questions](./docs/user-docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed. +Headless auto-responds to interactive prompts, detects completion, and exits with structured codes: `0` complete, `1` error/timeout, `2` blocked. Auto-restarts on crash with exponential backoff. Use `sf headless query` for instant, machine-readable state inspection — returns phase, next dispatch preview, and parallel worker costs as a single JSON object without spawning an LLM session. Pair with [remote questions](./docs/user-docs/remote-questions.md) to route decisions to Slack or Discord when human input is needed. -**Multi-session orchestration** — headless mode supports file-based IPC in `.gsd/parallel/` for coordinating multiple SF workers across milestones. Build orchestrators that spawn, monitor, and budget-cap a fleet of SF workers. +**Multi-session orchestration** — headless mode supports file-based IPC in `.sf/parallel/` for coordinating multiple SF workers across milestones. Build orchestrators that spawn, monitor, and budget-cap a fleet of SF workers. ### First launch -On first run, SF launches a branded setup wizard that walks you through LLM provider selection (OAuth or API key), then optional tool API keys (Brave Search, Context7, Jina, Slack, Discord). Every step is skippable — press Enter to skip any. If you have an existing Pi installation, your provider credentials (LLM and tool keys) are imported automatically. Run `gsd config` anytime to re-run the wizard. +On first run, SF launches a branded setup wizard that walks you through LLM provider selection (OAuth or API key), then optional tool API keys (Brave Search, Context7, Jina, Slack, Discord). Every step is skippable — press Enter to skip any. If you have an existing Pi installation, your provider credentials (LLM and tool keys) are imported automatically. Run `sf config` anytime to re-run the wizard. ### Commands | Command | What it does | | ----------------------- | --------------------------------------------------------------- | -| `/gsd` | Step mode — executes one unit at a time, pauses between each | -| `/gsd next` | Explicit step mode (same as bare `/gsd`) | -| `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | -| `/gsd quick` | Execute a quick task with SF guarantees, skip planning overhead | -| `/gsd stop` | Stop auto mode gracefully | -| `/gsd steer` | Hard-steer plan documents during execution | -| `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | -| `/gsd rethink` | Conversational project reorganization | -| `/gsd mcp` | MCP server status and connectivity | -| `/gsd status` | Progress dashboard | -| `/gsd queue` | Queue future milestones (safe during auto mode) | -| `/gsd prefs` | Model selection, timeouts, budget ceiling | -| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | -| `/gsd help` | Categorized command reference for all SF subcommands | -| `/gsd mode` | Switch workflow mode (solo/team) with coordinated defaults | -| `/gsd forensics` | Full-access SF debugger for auto-mode failure investigation | -| `/gsd cleanup` | Archive phase directories from completed milestones | -| `/gsd doctor` | Runtime health checks — issues surface across widget, visualizer, and reports | -| `/gsd keys` | API key manager — list, add, remove, test, rotate, doctor | -| `/gsd logs` | Browse activity, debug, and metrics logs | -| `/gsd export --html` | Generate HTML report for current or completed milestone | +| `/sf` | Step mode — executes one unit at a time, pauses between each | +| `/sf next` | Explicit step mode (same as bare `/sf`) | +| `/sf auto` | Autonomous mode — researches, plans, executes, commits, repeats | +| `/sf quick` | Execute a quick task with SF guarantees, skip planning overhead | +| `/sf stop` | Stop auto mode gracefully | +| `/sf steer` | Hard-steer plan documents during execution | +| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) | +| `/sf rethink` | Conversational project reorganization | +| `/sf mcp` | MCP server status and connectivity | +| `/sf status` | Progress dashboard | +| `/sf queue` | Queue future milestones (safe during auto mode) | +| `/sf prefs` | Model selection, timeouts, budget ceiling | +| `/sf migrate` | Migrate a v1 `.planning` directory to `.sf` format | +| `/sf help` | Categorized command reference for all SF subcommands | +| `/sf mode` | Switch workflow mode (solo/team) with coordinated defaults | +| `/sf forensics` | Full-access SF debugger for auto-mode failure investigation | +| `/sf cleanup` | Archive phase directories from completed milestones | +| `/sf doctor` | Runtime health checks — issues surface across widget, visualizer, and reports | +| `/sf keys` | API key manager — list, add, remove, test, rotate, doctor | +| `/sf logs` | Browse activity, debug, and metrics logs | +| `/sf export --html` | Generate HTML report for current or completed milestone | | `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove | | `/voice` | Toggle real-time speech-to-text (macOS, Linux) | | `/exit` | Graceful shutdown — saves session state before exiting | @@ -404,13 +404,13 @@ On first run, SF launches a branded setup wizard that walks you through LLM prov | `Ctrl+Alt+V` | Toggle voice transcription | | `Ctrl+Alt+B` | Show background shell processes | | `Alt+V` | Paste clipboard image (macOS) | -| `gsd config` | Re-run the setup wizard (LLM provider + tool keys) | -| `gsd update` | Update SF to the latest version | -| `gsd headless [cmd]` | Run `/gsd` commands without TUI (CI, cron, scripts) | -| `gsd headless query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) | -| `gsd --continue` (`-c`) | Resume the most recent session for the current directory | -| `gsd --worktree` (`-w`) | Launch an isolated worktree session for the active milestone | -| `gsd sessions` | Interactive session picker — browse and resume any saved session | +| `sf config` | Re-run the setup wizard (LLM provider + tool keys) | +| `sf update` | Update SF to the latest version | +| `sf headless [cmd]` | Run `/sf` commands without TUI (CI, cron, scripts) | +| `sf headless query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) | +| `sf --continue` (`-c`) | Resume the most recent session for the current directory | +| `sf --worktree` (`-w`) | Launch an isolated worktree session for the active milestone | +| `sf sessions` | Interactive session picker — browse and resume any saved session | --- @@ -446,7 +446,7 @@ main: feat(M001/S02): API endpoints and middleware feat(M001/S01): data model and type system -gsd/M001/S01 (deleted after merge): +sf/M001/S01 (deleted after merge): feat(S01/T03): file writer with round-trip fidelity feat(S01/T02): markdown parser for plan files feat(S01/T01): core types and interfaces @@ -466,7 +466,7 @@ The verification ladder: static checks → command execution → behavioral test ### Dashboard -`Ctrl+Alt+G` or `/gsd status` opens a real-time overlay showing: +`Ctrl+Alt+G` or `/sf status` opens a real-time overlay showing: - Current milestone, slice, and task progress - Auto mode elapsed time and phase @@ -476,12 +476,12 @@ The verification ladder: static checks → command execution → behavioral test ### HTML Reports -After a milestone completes, SF auto-generates a self-contained HTML report in `.gsd/reports/`. Each report includes project summary, progress tree, slice dependency graph (SVG DAG), cost/token metrics with bar charts, execution timeline, changelog, and knowledge base sections. No external dependencies — all CSS and JS are inlined, printable to PDF from any browser. +After a milestone completes, SF auto-generates a self-contained HTML report in `.sf/reports/`. Each report includes project summary, progress tree, slice dependency graph (SVG DAG), cost/token metrics with bar charts, execution timeline, changelog, and knowledge base sections. No external dependencies — all CSS and JS are inlined, printable to PDF from any browser. An auto-generated `index.html` shows all reports with progression metrics across milestones. - **Automatic** — generated after milestone completion (configurable via `auto_report` preference) -- **Manual** — run `/gsd export --html` anytime +- **Manual** — run `/sf export --html` anytime --- @@ -489,7 +489,7 @@ An auto-generated `index.html` shows all reports with progression metrics across ### Preferences -SF preferences live in `~/.gsd/PREFERENCES.md` (global) or `.gsd/PREFERENCES.md` (project). Manage with `/gsd prefs`. +SF preferences live in `~/.sf/PREFERENCES.md` (global) or `.sf/PREFERENCES.md` (project). Manage with `/sf prefs`. ```yaml --- @@ -542,11 +542,11 @@ auto_report: true Place an `AGENTS.md` file in any directory to provide persistent behavioral guidance for that scope. Pi core loads `AGENTS.md` automatically (with `CLAUDE.md` as a fallback) at both user and project levels. Use these files for coding standards, architectural decisions, domain terminology, or workflow preferences. -> **Note:** The legacy `agent-instructions.md` format (`~/.gsd/agent-instructions.md` and `.gsd/agent-instructions.md`) is deprecated and no longer loaded. Migrate any existing instructions to `AGENTS.md` or `CLAUDE.md`. +> **Note:** The legacy `agent-instructions.md` format (`~/.sf/agent-instructions.md` and `.sf/agent-instructions.md`) is deprecated and no longer loaded. Migrate any existing instructions to `AGENTS.md` or `CLAUDE.md`. ### Debug Mode -Start SF with `gsd --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting auto-mode issues. +Start SF with `sf --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting auto-mode issues. ### Token Optimization @@ -615,48 +615,48 @@ Five specialized subagents for delegated work: ## Working in teams -The best practice for working in teams is to ensure unique milestone names across all branches (by using `unique_milestone_ids`) and checking in the right `.gsd/` artifacts to share valuable context between teammates. +The best practice for working in teams is to ensure unique milestone names across all branches (by using `unique_milestone_ids`) and checking in the right `.sf/` artifacts to share valuable context between teammates. ### Suggested .gitignore setup ```bash # ── SF: Runtime / Ephemeral (per-developer, per-session) ────────────────── # Crash detection sentinel — PID lock, written per auto-mode session -.gsd/auto.lock +.sf/auto.lock # Auto-mode dispatch tracker — prevents re-running completed units (includes archived per-milestone files) -.gsd/completed-units*.json +.sf/completed-units*.json # State manifest — workflow state for recovery -.gsd/state-manifest.json +.sf/state-manifest.json # Derived state cache — regenerated from plan/roadmap files on disk -.gsd/STATE.md +.sf/STATE.md # Per-developer token/cost accumulator -.gsd/metrics.json +.sf/metrics.json # Raw JSONL session dumps — crash recovery forensics, auto-pruned -.gsd/activity/ +.sf/activity/ # Unit execution records — dispatch phase, timeouts, recovery tracking -.gsd/runtime/ +.sf/runtime/ # Git worktree working copies -.gsd/worktrees/ +.sf/worktrees/ # Parallel orchestration IPC and worker status -.gsd/parallel/ +.sf/parallel/ # SQLite database and WAL sidecars — checkpoint state, forensics data -.gsd/gsd.db* +.sf/sf.db* # Daily-rotated event journal — structured event log for forensics -.gsd/journal/ +.sf/journal/ # Doctor run history — diagnostic check results -.gsd/doctor-history.jsonl +.sf/doctor-history.jsonl # Workflow event log — structured event stream -.gsd/event-log.jsonl -# Generated HTML reports (regenerable via /gsd export --html) -.gsd/reports/ +.sf/event-log.jsonl +# Generated HTML reports (regenerable via /sf export --html) +.sf/reports/ # Session-specific interrupted-work markers -.gsd/milestones/**/continue.md -.gsd/milestones/**/*-CONTINUE.md +.sf/milestones/**/continue.md +.sf/milestones/**/*-CONTINUE.md ``` ### Unique Milestone Names -Create or amend your `.gsd/PREFERENCES.md` file within the repo to include `unique_milestone_ids: true` e.g. +Create or amend your `.sf/PREFERENCES.md` file within the repo to include `unique_milestone_ids: true` e.g. ```markdown --- @@ -665,16 +665,16 @@ unique_milestone_ids: true --- ``` -With the above `.gitignore` set up, the `.gsd/PREFERENCES.md` file is checked into the repo ensuring all teammates use unique milestone names to avoid collisions. +With the above `.gitignore` set up, the `.sf/PREFERENCES.md` file is checked into the repo ensuring all teammates use unique milestone names to avoid collisions. Milestone names will now be generated with a 6 char random string appended e.g. instead of `M001` you'll get something like `M001-ush8s3` -### Migrating an existing git ignored `.gsd/` folder +### Migrating an existing git ignored `.sf/` folder 1. Ensure you are not in the middle of any milestones (clean state) -2. Update the `.gsd/` related entries in your `.gitignore` to follow the `Suggested .gitignore setup` section under `Working in teams` (ensure you are no longer blanket ignoring the whole `.gsd/` directory) -3. Update your `.gsd/PREFERENCES.md` file within the repo as per section `Unique Milestone Names` -4. If you want to update all your existing milestones use this prompt in SF: `I have turned on unique milestone ids, please update all old milestone ids to use this new format e.g. M001-abc123 where abc123 is a random 6 char lowercase alpha numeric string. Update all references in all .gsd file contents, file names and directory names. Validate your work once done to ensure referential integrity.` +2. Update the `.sf/` related entries in your `.gitignore` to follow the `Suggested .gitignore setup` section under `Working in teams` (ensure you are no longer blanket ignoring the whole `.sf/` directory) +3. Update your `.sf/PREFERENCES.md` file within the repo as per section `Unique Milestone Names` +4. If you want to update all your existing milestones use this prompt in SF: `I have turned on unique milestone ids, please update all old milestone ids to use this new format e.g. M001-abc123 where abc123 is a random 6 char lowercase alpha numeric string. Update all references in all .sf file contents, file names and directory names. Validate your work once done to ensure referential integrity.` 5. Commit to git --- @@ -684,16 +684,16 @@ Milestone names will now be generated with a 6 char random string appended e.g. SF is a TypeScript application that embeds the Pi coding agent SDK. ``` -gsd (CLI binary) +sf (CLI binary) └─ loader.ts Sets PI_PACKAGE_DIR, SF env vars, dynamic-imports cli.ts └─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode ├─ headless.ts Headless orchestrator (spawns RPC child, auto-responds, detects completion) ├─ onboarding.ts First-run setup wizard (LLM provider + tool keys) ├─ wizard.ts Env hydration from stored auth.json credentials - ├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json - ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/ + ├─ app-paths.ts ~/.sf/agent/, ~/.sf/sessions/, auth.json + ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.sf/agent/ └─ src/resources/ - ├─ extensions/gsd/ Core SF extension (auto, state, commands, ...) + ├─ extensions/sf/ Core SF extension (auto, state, commands, ...) ├─ extensions/... 21 supporting extensions ├─ agents/ scout, researcher, worker, javascript-pro, typescript-pro └─ SF-WORKFLOW.md Manual bootstrap protocol @@ -703,8 +703,8 @@ gsd (CLI binary) - **`pkg/` shim directory** — `PI_PACKAGE_DIR` points here (not project root) to avoid Pi's theme resolution collision with our `src/` directory. Contains only `piConfig` and theme assets. - **Two-file loader pattern** — `loader.ts` sets all env vars with zero SDK imports, then dynamic-imports `cli.ts` which does static SDK imports. This ensures `PI_PACKAGE_DIR` is set before any SDK code evaluates. -- **Always-overwrite sync** — `npm update -g` takes effect immediately. Bundled extensions and agents are synced to `~/.gsd/agent/` on every launch, not just first run. -- **State lives on disk** — `.gsd/` is the source of truth. Auto mode reads it, writes it, and advances based on what it finds. No in-memory state survives across sessions. +- **Always-overwrite sync** — `npm update -g` takes effect immediately. Bundled extensions and agents are synced to `~/.sf/agent/` on every launch, not just first run. +- **State lives on disk** — `.sf/` is the source of truth. Auto mode reads it, writes it, and advances based on what it finds. No in-memory state survives across sessions. --- @@ -750,7 +750,7 @@ If you have a **Claude Max**, **Codex**, or **GitHub Copilot** subscription, you ### Per-Phase Model Selection -In your preferences (`/gsd prefs`), assign different models to different phases: +In your preferences (`/sf prefs`), assign different models to different phases: ```yaml models: @@ -793,6 +793,6 @@ Use expensive models where quality matters (planning, complex execution) and che **The original SF showed what was possible. This version delivers it.** -**`npm install -g sf-run && gsd`** +**`npm install -g sf-run && sf`** diff --git a/docker/README.md b/docker/README.md index 04d459fd0..2e12a33b6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -31,13 +31,13 @@ Docker Sandboxes provide MicroVM isolation — each sandbox runs in a lightweigh ```bash # Create a sandbox from the template -docker sandbox create --template ./docker --name gsd-sandbox +docker sandbox create --template ./docker --name sf-sandbox # Shell into the sandbox -docker sandbox exec -it gsd-sandbox bash +docker sandbox exec -it sf-sandbox bash # Inside the sandbox, run SF -gsd auto "implement the feature described in issue #42" +sf auto "implement the feature described in issue #42" ``` ### Option B: Docker Compose @@ -53,15 +53,15 @@ cp docker/.env.example docker/.env docker compose -f docker/docker-compose.yaml up -d # 3. Shell into the container -docker exec -it gsd-sandbox bash +docker exec -it sf-sandbox bash # 4. Run SF inside the container -gsd auto "implement the feature described in issue #42" +sf auto "implement the feature described in issue #42" ``` ## UID/GID Remapping -The entrypoint handles UID/GID remapping via `PUID` and `PGID` environment variables. This avoids permission issues on bind-mounted volumes by matching the container's `gsd` user to your host UID/GID. +The entrypoint handles UID/GID remapping via `PUID` and `PGID` environment variables. This avoids permission issues on bind-mounted volumes by matching the container's `sf` user to your host UID/GID. ```bash # Find your host UID/GID @@ -75,12 +75,12 @@ Set these in your `.env` file or in the `environment` section of the compose fil The container entrypoint (`entrypoint.sh`) runs four steps on every start: -1. **UID/GID remapping** — adjusts the `gsd` user to match `PUID`/`PGID` +1. **UID/GID remapping** — adjusts the `sf` user to match `PUID`/`PGID` 2. **Pre-create critical files** — prevents Docker bind-mount from creating directories where files are expected 3. **Sentinel-based bootstrap** — runs `bootstrap.sh` exactly once on first boot -4. **Drop privileges** — `exec gosu gsd` for proper PID 1 signal forwarding +4. **Drop privileges** — `exec gosu sf` for proper PID 1 signal forwarding -No hardcoded `user:` directive in compose — the entrypoint starts as root, remaps, then drops to `gsd`. +No hardcoded `user:` directive in compose — the entrypoint starts as root, remaps, then drops to `sf`. ## Two-Terminal Workflow @@ -88,12 +88,12 @@ SF's recommended workflow uses two terminals — one for auto mode, one for inte ```bash # Terminal 1: auto mode -docker sandbox exec -it gsd-sandbox bash -gsd auto "your task description" +docker sandbox exec -it sf-sandbox bash +sf auto "your task description" # Terminal 2: discuss / monitor -docker sandbox exec -it gsd-sandbox bash -gsd discuss +docker sandbox exec -it sf-sandbox bash +sf discuss ``` With Docker Compose, replace `docker sandbox exec` with `docker exec`. @@ -131,7 +131,7 @@ docker compose -f docker/docker-compose.yaml build --build-arg SF_VERSION=2.51.0 ```bash # Docker Sandbox -docker sandbox rm gsd-sandbox +docker sandbox rm sf-sandbox # Docker Compose docker compose -f docker/docker-compose.yaml down -v diff --git a/docs/README.md b/docs/README.md index 8a35257ea..f0bc187b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,8 +27,8 @@ Simplified Chinese translation: [`zh-CN/`](./zh-CN/). | [Working in Teams](./user-docs/working-in-teams.md) | Unique milestone IDs, `.gitignore` setup, and shared planning artifacts | | [Skills](./user-docs/skills.md) | Bundled skills, skill discovery, and custom skill authoring | | [Migration from v1](./user-docs/migration.md) | Migrating `.planning` directories from the original SF | -| [Troubleshooting](./user-docs/troubleshooting.md) | Common issues, `/gsd doctor` (real-time visibility v2.40), `/gsd forensics` (full debugger v2.40), and recovery procedures | -| [Web Interface](./user-docs/web-interface.md) | Browser-based project management with `gsd --web` (v2.41) | +| [Troubleshooting](./user-docs/troubleshooting.md) | Common issues, `/sf doctor` (real-time visibility v2.40), `/sf forensics` (full debugger v2.40), and recovery procedures | +| [Web Interface](./user-docs/web-interface.md) | Browser-based project management with `sf --web` (v2.41) | | [VS Code Extension](../vscode-extension/README.md) | Chat participant, sidebar dashboard, and RPC integration for VS Code | ## Architecture & Internals @@ -43,7 +43,7 @@ Design documents, ADRs, and internal references. Located in [`dev/`](./dev/). | [ADR-003: Pipeline Simplification](./dev/ADR-003-pipeline-simplification.md) | Research merged into planning, mechanical completion (v2.30) | | [ADR-004: Capability-Aware Model Routing](./dev/ADR-004-capability-aware-model-routing.md) | Extend routing from tier/cost selection to task-capability matching | | [ADR-007: Model Catalog Split](./dev/ADR-007-model-catalog-split.md) | Separate model metadata from routing logic for extensibility | -| [ADR-008: SF Tools over MCP](./dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md) | Native tools over MCP for provider parity | +| [ADR-008: SF Tools over MCP](./dev/ADR-008-sf-tools-over-mcp-for-provider-parity.md) | Native tools over MCP for provider parity | | [ADR-008: Implementation Plan](./dev/ADR-008-IMPLEMENTATION-PLAN.md) | Implementation plan for ADR-008 | | [Context Optimization Opportunities](./dev/pi-context-optimization-opportunities.md) | Analysis of context window usage and optimization strategies | | [File System Map](./dev/FILE-SYSTEM-MAP.md) | Complete file system reference | diff --git a/docs/dev/ADR-001-branchless-worktree-architecture.md b/docs/dev/ADR-001-branchless-worktree-architecture.md index f952dd1bf..10f43c688 100644 --- a/docs/dev/ADR-001-branchless-worktree-architecture.md +++ b/docs/dev/ADR-001-branchless-worktree-architecture.md @@ -7,9 +7,9 @@ ## Context -SF uses git for isolation during autonomous coding sessions. The current architecture (shipped in M003, v2.13.0) creates a **worktree per milestone** with **slice branches inside each worktree**. Each slice (`S01`, `S02`, ...) gets its own branch (`gsd/M001/S01`) within the worktree, which merges back to the milestone branch (`milestone/M001`) via `--no-ff` when the slice completes. The milestone branch squash-merges to `main` when the milestone completes. +SF uses git for isolation during autonomous coding sessions. The current architecture (shipped in M003, v2.13.0) creates a **worktree per milestone** with **slice branches inside each worktree**. Each slice (`S01`, `S02`, ...) gets its own branch (`sf/M001/S01`) within the worktree, which merges back to the milestone branch (`milestone/M001`) via `--no-ff` when the slice completes. The milestone branch squash-merges to `main` when the milestone completes. -This architecture replaced a previous "branch-per-slice" model that had severe `.gsd/` merge conflicts. M003 solved the merge conflicts but retained slice branches inside worktrees, inheriting complexity that has produced persistent, user-facing failures. +This architecture replaced a previous "branch-per-slice" model that had severe `.sf/` merge conflicts. M003 solved the merge conflicts but retained slice branches inside worktrees, inheriting complexity that has produced persistent, user-facing failures. ### Problems @@ -19,11 +19,11 @@ When `research-slice` or `plan-slice` dispatches, the agent writes artifacts (e. Documented in the auto-stop architecture doc as "The Branch-Switching Problem." -**2. `.gsd/` state clobbering across branches** +**2. `.sf/` state clobbering across branches** -`.gsd/` is gitignored (line 52 of `.gitignore`: `.gsd/`). Planning artifacts (roadmaps, plans, summaries, decisions, requirements) live in `.gsd/milestones/` but are invisible to git. When multiple branches or worktrees operate from the same repo, they share a single `.gsd/` directory on disk. Branch A's M001 roadmap overwrites Branch B's M001 roadmap. SF reads corrupted state, shows wrong milestone as complete, or enters infinite dispatch loops. +`.sf/` is gitignored (line 52 of `.gitignore`: `.sf/`). Planning artifacts (roadmaps, plans, summaries, decisions, requirements) live in `.sf/milestones/` but are invisible to git. When multiple branches or worktrees operate from the same repo, they share a single `.sf/` directory on disk. Branch A's M001 roadmap overwrites Branch B's M001 roadmap. SF reads corrupted state, shows wrong milestone as complete, or enters infinite dispatch loops. -The codebase has a contradictory workaround: `smartStage()` (git-service.ts:304-352) force-adds `SF_DURABLE_PATHS` (milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md) despite the `.gitignore`. This means `.gsd/milestones/` IS partially tracked on some branches but the gitignore claims otherwise. The code fights the configuration. +The codebase has a contradictory workaround: `smartStage()` (git-service.ts:304-352) force-adds `SF_DURABLE_PATHS` (milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md) despite the `.gitignore`. This means `.sf/milestones/` IS partially tracked on some branches but the gitignore claims otherwise. The code fights the configuration. **3. Merge/conflict code complexity** @@ -33,7 +33,7 @@ The current slice branch model requires: - `git-self-heal.ts` — 198 lines, 3 recovery functions for merge failures - `fix-merge` dispatch unit — dedicated LLM session to resolve conflicts the auto-resolver can't handle - `smartStage()` — 49 lines of runtime exclusion during staging -- Conflict categorization — 80 lines classifying `.gsd/` vs runtime vs code conflicts +- Conflict categorization — 80 lines classifying `.sf/` vs runtime vs code conflicts Total: **~582 lines** of merge/branch/conflict code across 3 files, plus the `fix-merge` prompt template and dispatch logic. This code exists solely because of slice branches. @@ -45,14 +45,14 @@ Branch-mode (`git-service.ts:mergeSliceToMain`) and worktree-mode (`auto-worktre - v2.11.1: URGENT fix for parse cache staleness causing repeated unit dispatch (directly caused by branch switching invalidation timing) - v2.13.1: Windows hotfix for multi-line commit messages in `mergeSliceToMilestone` -- 15+ separate bug fixes for `.gsd/` merge conflicts in the pre-M003 era +- 15+ separate bug fixes for `.sf/` merge conflicts in the pre-M003 era - Persistent user complaints about loop detection failures and state corruption ## Decision **Eliminate slice branches entirely.** All work within a milestone worktree commits sequentially on a single branch (`milestone/`). No branch creation, no branch switching, no slice merges, no conflict resolution within a worktree. -Track `.gsd/` planning artifacts in git. Gitignore only runtime/ephemeral state. +Track `.sf/` planning artifacts in git. Gitignore only runtime/ephemeral state. ### The Architecture @@ -92,49 +92,49 @@ main ───────────────────────── | Branch switching | Never happens. All work on one branch. | | Conflict resolution | No merges within a worktree means no conflicts within a worktree. | -### `.gsd/` Tracking Model +### `.sf/` Tracking Model **Tracked in git (travels with the branch):** ``` -.gsd/milestones/ — roadmaps, plans, summaries, research, contexts, task plans/summaries -.gsd/PROJECT.md — project overview -.gsd/DECISIONS.md — architectural decision register -.gsd/REQUIREMENTS.md — requirements register -.gsd/QUEUE.md — work queue +.sf/milestones/ — roadmaps, plans, summaries, research, contexts, task plans/summaries +.sf/PROJECT.md — project overview +.sf/DECISIONS.md — architectural decision register +.sf/REQUIREMENTS.md — requirements register +.sf/QUEUE.md — work queue ``` **Gitignored (ephemeral, runtime, infrastructure):** ``` -.gsd/runtime/ — dispatch records, timeout tracking -.gsd/activity/ — JSONL session dumps -.gsd/worktrees/ — git worktree working directories -.gsd/auto.lock — crash detection sentinel -.gsd/metrics.json — token/cost accumulator -.gsd/completed-units.json — dispatch idempotency tracker -.gsd/STATE.md — derived state cache (rebuilt by deriveState()) -.gsd/gsd.db — SQLite cache (rebuilt from tracked markdown by importers) -.gsd/DISCUSSION-MANIFEST.json — discussion phase tracking -.gsd/milestones/**/*-CONTINUE.md — interrupted-work markers -.gsd/milestones/**/continue.md — legacy continue markers +.sf/runtime/ — dispatch records, timeout tracking +.sf/activity/ — JSONL session dumps +.sf/worktrees/ — git worktree working directories +.sf/auto.lock — crash detection sentinel +.sf/metrics.json — token/cost accumulator +.sf/completed-units.json — dispatch idempotency tracker +.sf/STATE.md — derived state cache (rebuilt by deriveState()) +.sf/sf.db — SQLite cache (rebuilt from tracked markdown by importers) +.sf/DISCUSSION-MANIFEST.json — discussion phase tracking +.sf/milestones/**/*-CONTINUE.md — interrupted-work markers +.sf/milestones/**/continue.md — legacy continue markers ``` ### `.gitignore` Update -Replace the current blanket `.gsd/` ignore with explicit runtime-only ignores: +Replace the current blanket `.sf/` ignore with explicit runtime-only ignores: ```gitignore # ── SF: Runtime / Ephemeral ───────────────────────────────── -.gsd/auto.lock -.gsd/completed-units.json -.gsd/STATE.md -.gsd/metrics.json -.gsd/gsd.db -.gsd/activity/ -.gsd/runtime/ -.gsd/worktrees/ -.gsd/DISCUSSION-MANIFEST.json -.gsd/milestones/**/*-CONTINUE.md -.gsd/milestones/**/continue.md +.sf/auto.lock +.sf/completed-units.json +.sf/STATE.md +.sf/metrics.json +.sf/sf.db +.sf/activity/ +.sf/runtime/ +.sf/worktrees/ +.sf/DISCUSSION-MANIFEST.json +.sf/milestones/**/*-CONTINUE.md +.sf/milestones/**/continue.md ``` Planning artifacts (milestones/, PROJECT.md, DECISIONS.md, REQUIREMENTS.md, QUEUE.md) are NOT in `.gitignore` and are tracked normally. @@ -163,7 +163,7 @@ The function simplifies dramatically: 5. `git commit` with milestone summary 6. Remove worktree + delete branch -No conflict categorization. No runtime file stripping. No `.gsd/` special handling. Planning artifacts merge cleanly because they're in `.gsd/milestones/M001/` which doesn't exist on `main` until this merge. +No conflict categorization. No runtime file stripping. No `.sf/` special handling. Planning artifacts merge cleanly because they're in `.sf/milestones/M001/` which doesn't exist on `main` until this merge. ### What `smartStage()` Becomes @@ -192,7 +192,7 @@ The `fix-merge` dispatch unit type is eliminated. Within a worktree, there are n The `shouldUseWorktreeIsolation()` three-tier preference resolution is replaced by a single behavior: worktree isolation is always used. The `git.isolation: "branch"` preference is deprecated. -Projects with existing `gsd/M001/S01` slice branches can still be read by state derivation, but new work never creates slice branches. +Projects with existing `sf/M001/S01` slice branches can still be read by state derivation, but new work never creates slice branches. ### Risks @@ -209,7 +209,7 @@ Squash merge collapses all commits into one on `main`. Mitigations: **3. SQLite DB desync after `git reset`** -If tracked markdown rolls back via `git reset --hard`, the gitignored `gsd.db` doesn't. Mitigation: the importer layer (M001/S02) rebuilds the DB from markdown on startup. The DB is a cache, markdown is truth. +If tracked markdown rolls back via `git reset --hard`, the gitignored `sf.db` doesn't. Mitigation: the importer layer (M001/S02) rebuilds the DB from markdown on startup. The DB is a cache, markdown is truth. **4. Disk space with multiple worktrees** @@ -223,15 +223,15 @@ After `research-slice` or `plan-slice`, immediately merge the slice branch back **Rejected:** Adds another merge path instead of removing the root cause. Still requires conflict resolution, self-healing, branch switching. -### B. Keep `.gsd/` gitignored, bootstrap from git history for manual worktrees +### B. Keep `.sf/` gitignored, bootstrap from git history for manual worktrees -When SF detects an empty `.gsd/` in a worktree, reconstruct state from the branch's git history using `git show :.gsd/...`. +When SF detects an empty `.sf/` in a worktree, reconstruct state from the branch's git history using `git show :.sf/...`. **Rejected:** Recovery logic, not architecture. Doesn't fix the fundamental problem of branch-agnostic state. Fails when git history has been rewritten. -### C. Branch-scoped `.gsd/` directories (`.gsd/branches//milestones/...`) +### C. Branch-scoped `.sf/` directories (`.sf/branches//milestones/...`) -Each branch writes to a namespaced subdirectory within `.gsd/`. +Each branch writes to a namespaced subdirectory within `.sf/`. **Rejected:** Adds complexity instead of removing it. Requires renaming/moving on branch creation, doesn't work with standard git tools (`git checkout` doesn't rename directories). @@ -243,7 +243,7 @@ This architecture was stress-tested by three independent models: **GPT-5.4 (Codex)** read the full codebase and confirmed the model is sound. Identified that `smartStage()` already force-adds durable paths (validating the tracked-artifact approach) and that `resolveMainWorktreeRoot` in PR #487 is architecturally wrong (adopted — PR to be closed). -**Codebase analysis** confirmed `.gsd/milestones/` is already partially tracked on `main` despite the `.gitignore`, that `SF_DURABLE_PATHS` exists as a code-level acknowledgment that planning artifacts should be tracked, and that the README already documents the correct runtime-only gitignore pattern. +**Codebase analysis** confirmed `.sf/milestones/` is already partially tracked on `main` despite the `.gitignore`, that `SF_DURABLE_PATHS` exists as a code-level acknowledgment that planning artifacts should be tracked, and that the README already documents the correct runtime-only gitignore pattern. ### Codex (GPT-5.4) Dissent — "No Slice Branches Is a Redesign" @@ -255,7 +255,7 @@ Rebuttal: In the branchless model, there is no integration step to crash between **Concern 2: "Concurrent edits to shared root docs (PROJECT.md, DECISIONS.md) from two terminals."** -Rebuttal: Valid edge case. If `/gsd queue` edits `DECISIONS.md` on `main` while auto-mode edits it in a worktree, there's a content conflict at squash-merge time. This is a standard git content conflict — no different from two developers editing the same file. Handled by normal merge resolution. Not caused by or solved by slice branches. +Rebuttal: Valid edge case. If `/sf queue` edits `DECISIONS.md` on `main` while auto-mode edits it in a worktree, there's a content conflict at squash-merge time. This is a standard git content conflict — no different from two developers editing the same file. Handled by normal merge resolution. Not caused by or solved by slice branches. **Concern 3: "Slice→milestone merges provide continuous integration. Removing them pushes conflict discovery to the end."** @@ -263,7 +263,7 @@ Rebuttal: In a single-user sequential workflow, there is nothing to integrate ag **Concern 4: "Replace slice branches with another explicit slice-boundary primitive. Don't just delete them."** -Response: Accepted in spirit. Commits with conventional tags (`feat(M001/S01):`, `feat(M001/S01/T01):`) serve as the slice boundary primitive. `git log --grep="M001/S01"` isolates a slice's history. `git revert` targets specific commits. Git tags (`gsd/M001/S01-complete`) can mark slice completion if needed. The boundary primitive is commit metadata, not branches. +Response: Accepted in spirit. Commits with conventional tags (`feat(M001/S01):`, `feat(M001/S01/T01):`) serve as the slice boundary primitive. `git log --grep="M001/S01"` isolates a slice's history. `git revert` targets specific commits. Git tags (`sf/M001/S01-complete`) can mark slice completion if needed. The boundary primitive is commit metadata, not branches. ## Action Items diff --git a/docs/dev/ADR-003-pipeline-simplification.md b/docs/dev/ADR-003-pipeline-simplification.md index 61abf0c0b..a05981ee6 100644 --- a/docs/dev/ADR-003-pipeline-simplification.md +++ b/docs/dev/ADR-003-pipeline-simplification.md @@ -144,7 +144,7 @@ For the same 4-slice, 3-task milestone: - The plan-milestone prompt drops "Trust the research" — there is no research document to trust. - The RESEARCH.md artifact becomes optional. If the planner wants to capture notes for downstream reference, it can write one. But it's not required, and downstream units don't depend on it. - Skill discovery instructions move into the plan-milestone prompt. -- The research-milestone template (`prompts/research-milestone.md`) is retained but only used when explicitly dispatched via `/gsd dispatch research`. +- The research-milestone template (`prompts/research-milestone.md`) is retained but only used when explicitly dispatched via `/sf dispatch research`. **Token savings:** ~1 full session (12–37K tokens of prompt context) + the RESEARCH.md document no longer re-inlined into plan-milestone (~5–15K tokens). @@ -231,7 +231,7 @@ The aggregator reads `T##-VERIFY.json` as the primary source of truth, supplemen - A new `aggregateMilestoneVerification()` function collects `T##-VERIFY.json` files and `S##-UAT-RESULT.md` files across all slices. - The function produces a VALIDATION.md with per-task and per-slice pass/fail status, UAT evidence, and an overall verdict. - The LLM-driven validate-milestone session is removed from the default pipeline. -- The validate-milestone template is retained for explicit dispatch (users who want LLM-driven validation can run `/gsd dispatch validate`). +- The validate-milestone template is retained for explicit dispatch (users who want LLM-driven validation can run `/sf dispatch validate`). - The `skip_milestone_validation` preference (which writes a pass-through VALIDATION.md) becomes the default behavior, with the mechanical aggregation replacing it. ```typescript @@ -571,7 +571,7 @@ At current Opus pricing ($15/MTok input, $75/MTok output — as of March 2026), - The **crash recovery**, **idempotency**, and **stuck detection** systems (fewer sessions means these fire less often, but the safety nets remain) - The **metrics** and **cost tracking** systems - The **parallel orchestrator** for independent milestones -- All prompt templates are **retained** — for fallback, recovery, and explicit dispatch via `/gsd dispatch ` +- All prompt templates are **retained** — for fallback, recovery, and explicit dispatch via `/sf dispatch ` ### What Gets Simpler Downstream @@ -683,7 +683,7 @@ The mechanical summary quality might be insufficient for complex slices. 4. Remove dispatch rule "planning (no research, not S01) → research-slice" 5. Update `plan-milestone.md` and `plan-slice.md` prompt templates 6. Make `skip_research` and `skip_slice_research` preferences default to true (backwards compat) -7. Retain research templates for explicit `/gsd dispatch research` use +7. Retain research templates for explicit `/sf dispatch research` use 8. **Targeted inlining reduction for planning sessions:** Move DECISIONS, REQUIREMENTS, PROJECT to path references in plan-milestone and plan-slice prompts. Keep ROADMAP and CONTEXT inlined. This prevents context pressure from the added exploration work. ### Phase 2: Mechanical slice completion diff --git a/docs/dev/ADR-004-capability-aware-model-routing.md b/docs/dev/ADR-004-capability-aware-model-routing.md index f381229ee..bd70195c6 100644 --- a/docs/dev/ADR-004-capability-aware-model-routing.md +++ b/docs/dev/ADR-004-capability-aware-model-routing.md @@ -232,7 +232,7 @@ Partial overrides are deep-merged with built-in defaults. This uses the same `mo ### Profile Versioning -Built-in capability profiles are maintained alongside the existing `MODEL_CAPABILITY_TIER` and `MODEL_COST_PER_1K_INPUT` tables in `model-router.ts`. When the `@gsd/pi-ai` model catalog is updated with new models, the capability profile table must be updated in the same PR. A linting rule should flag any model present in `MODEL_CAPABILITY_TIER` but missing from `MODEL_CAPABILITY_PROFILES`. +Built-in capability profiles are maintained alongside the existing `MODEL_CAPABILITY_TIER` and `MODEL_COST_PER_1K_INPUT` tables in `model-router.ts`. When the `@sf/pi-ai` model catalog is updated with new models, the capability profile table must be updated in the same PR. A linting rule should flag any model present in `MODEL_CAPABILITY_TIER` but missing from `MODEL_CAPABILITY_PROFILES`. Profiles are versioned implicitly by SF release. The existing `models.json` `modelOverrides` mechanism allows users to correct stale defaults immediately without waiting for a SF update. diff --git a/docs/dev/ADR-005-multi-model-provider-tool-strategy.md b/docs/dev/ADR-005-multi-model-provider-tool-strategy.md index 61550c2eb..ef7eb4c3f 100644 --- a/docs/dev/ADR-005-multi-model-provider-tool-strategy.md +++ b/docs/dev/ADR-005-multi-model-provider-tool-strategy.md @@ -63,5 +63,5 @@ Introduce a provider capability registry and tool compatibility layer that integ | `packages/pi-ai/src/providers/transform-messages.ts` | Cross-provider normalization | | `packages/pi-ai/src/types.ts` | Core types | | `packages/pi-coding-agent/src/core/extensions/types.ts` | ToolDefinition, ExtensionAPI | -| `src/resources/extensions/gsd/model-router.ts` | Capability scoring (ADR-004) | -| `src/resources/extensions/gsd/auto-model-selection.ts` | Model selection orchestration | +| `src/resources/extensions/sf/model-router.ts` | Capability scoring (ADR-004) | +| `src/resources/extensions/sf/auto-model-selection.ts` | Model selection orchestration | diff --git a/docs/dev/ADR-007-model-catalog-split.md b/docs/dev/ADR-007-model-catalog-split.md index e83fbbd3d..63d954b6e 100644 --- a/docs/dev/ADR-007-model-catalog-split.md +++ b/docs/dev/ADR-007-model-catalog-split.md @@ -243,7 +243,7 @@ Make all providers load on-demand via async dynamic imports, generalizing the Be ### 2. Plugin architecture with separate npm packages -Move each provider to its own package (`@gsd/provider-anthropic`, etc.). Maximum isolation but dramatically more complex build/release/versioning. Overkill for a monorepo where all providers ship together. +Move each provider to its own package (`@sf/provider-anthropic`, etc.). Maximum isolation but dramatically more complex build/release/versioning. Overkill for a monorepo where all providers ship together. ### 3. Do nothing diff --git a/docs/dev/ADR-008-IMPLEMENTATION-PLAN.md b/docs/dev/ADR-008-IMPLEMENTATION-PLAN.md index e815dd55a..c06d64125 100644 --- a/docs/dev/ADR-008-IMPLEMENTATION-PLAN.md +++ b/docs/dev/ADR-008-IMPLEMENTATION-PLAN.md @@ -1,6 +1,6 @@ # ADR-008 Implementation Plan -**Related ADR:** [ADR-008-gsd-tools-over-mcp-for-provider-parity.md](/Users/jeremymcspadden/Github/gsd-2/docs/ADR-008-gsd-tools-over-mcp-for-provider-parity.md) +**Related ADR:** [ADR-008-sf-tools-over-mcp-for-provider-parity.md](/Users/jeremymcspadden/Github/sf-2/docs/ADR-008-sf-tools-over-mcp-for-provider-parity.md) **Status:** Draft **Date:** 2026-04-09 @@ -36,9 +36,9 @@ Goal: separate business logic from transport registration. Targets: -- `src/resources/extensions/gsd/bootstrap/db-tools.ts` -- `src/resources/extensions/gsd/bootstrap/query-tools.ts` -- `src/resources/extensions/gsd/tools/complete-task.ts` +- `src/resources/extensions/sf/bootstrap/db-tools.ts` +- `src/resources/extensions/sf/bootstrap/query-tools.ts` +- `src/resources/extensions/sf/tools/complete-task.ts` - sibling modules used by planning/summary/validation tools Deliverables: @@ -80,7 +80,7 @@ Likely files: Decisions to make during implementation: -- extend existing MCP package vs create `packages/mcp-gsd-tools-server` +- extend existing MCP package vs create `packages/mcp-sf-tools-server` - canonical names only vs selected alias export - single combined server vs separate “session” and “workflow” server modes @@ -95,7 +95,7 @@ Goal: ensure MCP mutations enforce the same rules as native tool calls. Targets: -- `src/resources/extensions/gsd/bootstrap/write-gate.ts` +- `src/resources/extensions/sf/bootstrap/write-gate.ts` - any current tool-call gating hooks tied to native runtime only - MCP wrapper layer before shared handler invocation @@ -158,7 +158,7 @@ Goal: keep the workflow contract strict while removing transport assumptions fro Targets: -- `src/resources/extensions/gsd/prompts/execute-task.md` +- `src/resources/extensions/sf/prompts/execute-task.md` - related planning/discuss prompts that reference tool availability - provider and MCP docs @@ -251,16 +251,16 @@ Verification: High-probability files for the first implementation: -- `src/resources/extensions/gsd/bootstrap/db-tools.ts` -- `src/resources/extensions/gsd/bootstrap/query-tools.ts` -- `src/resources/extensions/gsd/bootstrap/write-gate.ts` -- `src/resources/extensions/gsd/tools/complete-task.ts` +- `src/resources/extensions/sf/bootstrap/db-tools.ts` +- `src/resources/extensions/sf/bootstrap/query-tools.ts` +- `src/resources/extensions/sf/bootstrap/write-gate.ts` +- `src/resources/extensions/sf/tools/complete-task.ts` - `src/resources/extensions/claude-code-cli/stream-adapter.ts` - `src/resources/extensions/claude-code-cli/index.ts` - `packages/mcp-server/src/server.ts` - `packages/mcp-server/src/session-manager.ts` - `packages/mcp-server/README.md` -- `src/resources/extensions/gsd/prompts/execute-task.md` +- `src/resources/extensions/sf/prompts/execute-task.md` ## Testing Strategy diff --git a/docs/dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md b/docs/dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md index 0c8cf58a1..8fbb2b5bf 100644 --- a/docs/dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md +++ b/docs/dev/ADR-008-gsd-tools-over-mcp-for-provider-parity.md @@ -3,7 +3,7 @@ **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/gsd/bootstrap/db-tools.ts`, `src/resources/extensions/claude-code-cli/stream-adapter.ts`, `packages/mcp-server/src/server.ts` +**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 @@ -29,7 +29,7 @@ The core SF workflow tools are internal extension tools. Examples include: - `gsd_replan_slice` - `gsd_reassess_roadmap` -These are registered in `src/resources/extensions/gsd/bootstrap/db-tools.ts` and related bootstrap files. SF prompts assume these tools are available during discuss, plan, and execute flows. +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: diff --git a/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md b/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md index 3236842fd..2ac1363aa 100644 --- a/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md +++ b/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md @@ -1,6 +1,6 @@ # ADR-009 Implementation Plan -**Related ADR:** [ADR-009-orchestration-kernel-refactor.md](/Users/jeremymcspadden/Github/gsd-2/docs/dev/ADR-009-orchestration-kernel-refactor.md) +**Related ADR:** [ADR-009-orchestration-kernel-refactor.md](/Users/jeremymcspadden/Github/sf-2/docs/dev/ADR-009-orchestration-kernel-refactor.md) **Status:** Draft **Date:** 2026-04-14 **Target Window:** 8-10 waves (incremental, no big-bang rewrite) @@ -49,10 +49,10 @@ Goal: define typed contracts and a new orchestration spine without changing beha Primary targets: -- `src/resources/extensions/gsd/auto.ts` -- `src/resources/extensions/gsd/auto/loop.ts` -- `src/resources/extensions/gsd/auto/types.ts` -- `src/resources/extensions/gsd/auto/session.ts` +- `src/resources/extensions/sf/auto.ts` +- `src/resources/extensions/sf/auto/loop.ts` +- `src/resources/extensions/sf/auto/types.ts` +- `src/resources/extensions/sf/auto/session.ts` Deliverables: @@ -66,11 +66,11 @@ Goal: normalize all checks into a unified gate runner. Primary targets: -- `src/resources/extensions/gsd/verification-gate.ts` -- `src/resources/extensions/gsd/auto-verification.ts` -- `src/resources/extensions/gsd/pre-execution-checks.ts` -- `src/resources/extensions/gsd/post-execution-checks.ts` -- `src/resources/extensions/gsd/milestone-validation-gates.ts` +- `src/resources/extensions/sf/verification-gate.ts` +- `src/resources/extensions/sf/auto-verification.ts` +- `src/resources/extensions/sf/pre-execution-checks.ts` +- `src/resources/extensions/sf/post-execution-checks.ts` +- `src/resources/extensions/sf/milestone-validation-gates.ts` Deliverables: @@ -84,11 +84,11 @@ Goal: enable any-model-any-phase through requirement-based selection plus policy Primary targets: -- `src/resources/extensions/gsd/model-router.ts` -- `src/resources/extensions/gsd/auto-model-selection.ts` -- `src/resources/extensions/gsd/preferences-models.ts` -- `src/resources/extensions/gsd/model-cost-table.ts` -- `src/resources/extensions/gsd/custom-execution-policy.ts` +- `src/resources/extensions/sf/model-router.ts` +- `src/resources/extensions/sf/auto-model-selection.ts` +- `src/resources/extensions/sf/preferences-models.ts` +- `src/resources/extensions/sf/model-cost-table.ts` +- `src/resources/extensions/sf/custom-execution-policy.ts` Deliverables: @@ -103,11 +103,11 @@ Goal: move to one DAG scheduler contract. Primary targets: -- `src/resources/extensions/gsd/reactive-graph.ts` -- `src/resources/extensions/gsd/slice-parallel-orchestrator.ts` -- `src/resources/extensions/gsd/parallel-orchestrator.ts` -- `src/resources/extensions/gsd/graph.ts` -- `src/resources/extensions/gsd/unit-runtime.ts` +- `src/resources/extensions/sf/reactive-graph.ts` +- `src/resources/extensions/sf/slice-parallel-orchestrator.ts` +- `src/resources/extensions/sf/parallel-orchestrator.ts` +- `src/resources/extensions/sf/graph.ts` +- `src/resources/extensions/sf/unit-runtime.ts` Deliverables: @@ -121,10 +121,10 @@ Goal: guarantee git action and metadata record per turn. Primary targets: -- `src/resources/extensions/gsd/git-service.ts` -- `src/resources/extensions/gsd/auto-post-unit.ts` -- `src/resources/extensions/gsd/auto-unit-closeout.ts` -- `src/resources/extensions/gsd/auto-worktree.ts` +- `src/resources/extensions/sf/git-service.ts` +- `src/resources/extensions/sf/auto-post-unit.ts` +- `src/resources/extensions/sf/auto-unit-closeout.ts` +- `src/resources/extensions/sf/auto-worktree.ts` Deliverables: @@ -138,11 +138,11 @@ Goal: unify journal/activity/metrics into a causal event model. Primary targets: -- `src/resources/extensions/gsd/journal.ts` -- `src/resources/extensions/gsd/activity-log.ts` -- `src/resources/extensions/gsd/metrics.ts` -- `src/resources/extensions/gsd/workflow-logger.ts` -- `src/resources/extensions/gsd/gsd-db.ts` +- `src/resources/extensions/sf/journal.ts` +- `src/resources/extensions/sf/activity-log.ts` +- `src/resources/extensions/sf/metrics.ts` +- `src/resources/extensions/sf/workflow-logger.ts` +- `src/resources/extensions/sf/sf-db.ts` Deliverables: @@ -156,11 +156,11 @@ Goal: formal multi-round clarify/research/draft/compile flow. Primary targets: -- `src/resources/extensions/gsd/guided-flow.ts` -- `src/resources/extensions/gsd/preparation.ts` -- `src/resources/extensions/gsd/auto/phases.ts` -- `src/resources/extensions/gsd/auto-prompts.ts` -- prompt templates under `src/resources/extensions/gsd/prompts/` +- `src/resources/extensions/sf/guided-flow.ts` +- `src/resources/extensions/sf/preparation.ts` +- `src/resources/extensions/sf/auto/phases.ts` +- `src/resources/extensions/sf/auto-prompts.ts` +- prompt templates under `src/resources/extensions/sf/prompts/` Deliverables: @@ -219,7 +219,7 @@ Exit criteria: Verification: -- targeted tests in `src/resources/extensions/gsd/tests/*auto*` +- targeted tests in `src/resources/extensions/sf/tests/*auto*` - `npm run test:unit` ## Wave 2: Gate Plane Unification @@ -288,7 +288,7 @@ Verification: - `slice-parallel-orchestrator.test.ts` - `slice-parallel-conflict.test.ts` - `sidecar-queue.test.ts` -- integration: `src/resources/extensions/gsd/tests/integration/*.test.ts` +- integration: `src/resources/extensions/sf/tests/integration/*.test.ts` ## Wave 5: GitOps Transactions Per Turn @@ -429,7 +429,7 @@ Verification: Expected schema additions: -- audit projection tables in `gsd.db` +- audit projection tables in `sf.db` - gate result persistence tables - turn transaction metadata diff --git a/docs/dev/ADR-009-orchestration-kernel-refactor.md b/docs/dev/ADR-009-orchestration-kernel-refactor.md index fe49142b9..7b8f953a9 100644 --- a/docs/dev/ADR-009-orchestration-kernel-refactor.md +++ b/docs/dev/ADR-009-orchestration-kernel-refactor.md @@ -320,7 +320,7 @@ Primary decomposition targets: - `auto.ts` -> orchestrator kernel + adapters - `auto-prompts.ts` -> plan compiler + prompt renderers - `state.ts` -> state query service + immutable state views -- `gsd-db.ts` -> data access layer + event projection store +- `sf-db.ts` -> data access layer + event projection store - `auto-post-unit.ts` / `auto-verification.ts` -> closeout gate services ## Acceptance Criteria diff --git a/docs/dev/ADR-010-pi-clean-seam-architecture.md b/docs/dev/ADR-010-pi-clean-seam-architecture.md index 24c217124..224c36569 100644 --- a/docs/dev/ADR-010-pi-clean-seam-architecture.md +++ b/docs/dev/ADR-010-pi-clean-seam-architecture.md @@ -13,10 +13,10 @@ SF vendors four packages from [pi-mono](https://github.com/badlogic/pi-mono) (an | Package | Role | Current version | |---|---|---| -| `@gsd/pi-agent-core` | Core agent loop and types | 0.57.1 | -| `@gsd/pi-ai` | Multi-provider LLM API | 0.57.1 | -| `@gsd/pi-tui` | Terminal UI framework | 0.57.1 | -| `@gsd/pi-coding-agent` | Coding agent, tools, extension system | 2.74.0 | +| `@sf/pi-agent-core` | Core agent loop and types | 0.57.1 | +| `@sf/pi-ai` | Multi-provider LLM API | 0.57.1 | +| `@sf/pi-tui` | Terminal UI framework | 0.57.1 | +| `@sf/pi-coding-agent` | Coding agent, tools, extension system | 2.74.0 | Vendoring was chosen over npm dependencies to allow SF to modify the upstream packages freely. However, over time, SF has written substantial original logic directly inside `pi-coding-agent` — approximately 79 files including: @@ -32,7 +32,7 @@ This SF-authored code is mixed in with upstream pi code inside the same package. Pi-mono does publish to npm as `@mariozechner/pi-*`. Moving to npm dependencies would eliminate vendoring entirely, but it is blocked by: -1. `@gsd/native` bindings are imported directly inside the vendored pi-tui and pi-coding-agent source — the upstream npm packages do not have these imports +1. `@sf/native` bindings are imported directly inside the vendored pi-tui and pi-coding-agent source — the upstream npm packages do not have these imports 2. ~50 direct source modification commits to the vendored packages since March 2026 would need to be evaluated individually 3. The upstream extension API (~25 events) is a subset of SF's extension system (~50+ events) — the delta would need to be re-architected before the move @@ -52,32 +52,32 @@ packages/ pi-ai/ # vendored upstream — no SF modifications pi-tui/ # vendored upstream — no SF modifications pi-coding-agent/ # vendored upstream + extension system (pi-typed, stays here) - gsd-agent-core/ # NEW — SF session orchestration layer - gsd-agent-modes/ # NEW — SF run modes and CLI layer + sf-agent-core/ # NEW — SF session orchestration layer + sf-agent-modes/ # NEW — SF run modes and CLI layer ``` ### Dependency graph ``` sf-run (binary) - └── @gsd/agent-modes - ├── @gsd/agent-core - │ ├── @gsd/pi-coding-agent - │ ├── @gsd/pi-agent-core - │ └── @gsd/pi-ai - └── @gsd/pi-coding-agent - ├── @gsd/pi-agent-core - ├── @gsd/pi-ai - └── @gsd/pi-tui + └── @sf/agent-modes + ├── @sf/agent-core + │ ├── @sf/pi-coding-agent + │ ├── @sf/pi-agent-core + │ └── @sf/pi-ai + └── @sf/pi-coding-agent + ├── @sf/pi-agent-core + ├── @sf/pi-ai + └── @sf/pi-tui ``` -Arrows point in one direction only. No cycles. The vendored pi packages have no knowledge of `@gsd/agent-core` or `@gsd/agent-modes`. +Arrows point in one direction only. No cycles. The vendored pi packages have no knowledge of `@sf/agent-core` or `@sf/agent-modes`. --- ## Package Specifications -### `@gsd/agent-core` (`packages/gsd-agent-core/`) +### `@sf/agent-core` (`packages/sf-agent-core/`) **Purpose:** SF's session orchestration layer. Owns the `AgentSession` class, compaction, bash execution, system prompt construction, and the `createAgentSession()` factory that wires everything together. @@ -119,13 +119,13 @@ export { BlobStore } from './blob-store.js' | `blob-store.ts` | External binary data management | | `export-html/` | Session HTML export | -**Key dependency note:** `agent-session.ts` imports pi types directly (`Agent`, `AgentEvent`, `AgentMessage`, `AgentState`, `AgentTool`, `ThinkingLevel` from `@gsd/pi-agent-core`; `Model`, `Message` from `@gsd/pi-ai`). This is intentional — SF's session layer is pi-typed, not abstracting over pi. This makes the seam a clear seam, not an abstraction. +**Key dependency note:** `agent-session.ts` imports pi types directly (`Agent`, `AgentEvent`, `AgentMessage`, `AgentState`, `AgentTool`, `ThinkingLevel` from `@sf/pi-agent-core`; `Model`, `Message` from `@sf/pi-ai`). This is intentional — SF's session layer is pi-typed, not abstracting over pi. This makes the seam a clear seam, not an abstraction. --- -### `@gsd/agent-modes` (`packages/gsd-agent-modes/`) +### `@sf/agent-modes` (`packages/sf-agent-modes/`) -**Purpose:** SF's run-mode and CLI layer. Assembles the agent session (from `@gsd/agent-core`) with a specific interface: interactive TUI, headless RPC server, or print output. Contains the `main()` entry point logic invoked by the `gsd` binary. +**Purpose:** SF's run-mode and CLI layer. Assembles the agent session (from `@sf/agent-core`) with a specific interface: interactive TUI, headless RPC server, or print output. Contains the `main()` entry point logic invoked by the `sf` binary. **Public API surface (exported from `index.ts`):** @@ -167,26 +167,26 @@ The extension system remains here because it is legitimately pi-typed. Extension **Required update to extension loader:** -`src/core/extensions/loader.ts` maintains a `STATIC_BUNDLED_MODULES` map of packages that extensions can import at runtime. After the migration, `@gsd/agent-core` and `@gsd/agent-modes` must be added to this map so that extensions importing those packages continue to resolve correctly in compiled Bun binaries: +`src/core/extensions/loader.ts` maintains a `STATIC_BUNDLED_MODULES` map of packages that extensions can import at runtime. After the migration, `@sf/agent-core` and `@sf/agent-modes` must be added to this map so that extensions importing those packages continue to resolve correctly in compiled Bun binaries: ```typescript // Before (current) const STATIC_BUNDLED_MODULES = { - "@gsd/pi-agent-core": _bundledPiAgentCore, - "@gsd/pi-ai": _bundledPiAi, - "@gsd/pi-tui": _bundledPiTui, - "@gsd/pi-coding-agent": _bundledPiCodingAgent, + "@sf/pi-agent-core": _bundledPiAgentCore, + "@sf/pi-ai": _bundledPiAi, + "@sf/pi-tui": _bundledPiTui, + "@sf/pi-coding-agent": _bundledPiCodingAgent, // ... } // After const STATIC_BUNDLED_MODULES = { - "@gsd/pi-agent-core": _bundledPiAgentCore, - "@gsd/pi-ai": _bundledPiAi, - "@gsd/pi-tui": _bundledPiTui, - "@gsd/pi-coding-agent": _bundledPiCodingAgent, - "@gsd/agent-core": _bundledGsdAgentCore, // NEW - "@gsd/agent-modes": _bundledGsdAgentModes, // NEW + "@sf/pi-agent-core": _bundledPiAgentCore, + "@sf/pi-ai": _bundledPiAi, + "@sf/pi-tui": _bundledPiTui, + "@sf/pi-coding-agent": _bundledPiCodingAgent, + "@sf/agent-core": _bundledGsdAgentCore, // NEW + "@sf/agent-modes": _bundledGsdAgentModes, // NEW // ... } ``` @@ -197,9 +197,9 @@ const STATIC_BUNDLED_MODULES = { 1. Download the new pi-mono release for the four vendored packages 2. Copy the upstream source into `packages/pi-agent-core/`, `pi-ai/`, `pi-tui/`, `pi-coding-agent/` - - Do not touch `packages/gsd-agent-core/` or `packages/gsd-agent-modes/` + - Do not touch `packages/sf-agent-core/` or `packages/sf-agent-modes/` 3. Run `tsc --noEmit` (or the build) across the workspace -4. Fix type errors in `@gsd/agent-core` and `@gsd/agent-modes` only +4. Fix type errors in `@sf/agent-core` and `@sf/agent-modes` only 5. If upstream changed the extension event API, fix extension system integration in `pi-coding-agent/src/core/extensions/` Steps 2-5 are scoped to known files. No archaeology required. @@ -210,9 +210,9 @@ Steps 2-5 are scoped to known files. No archaeology required. | Issue | Location | Fix | |---|---|---| -| Internal-path import of `AgentSessionEvent` | `src/web/bridge-service.ts` | Import from `@gsd/agent-core` public export | -| `clearQueue()` not in typed public API | `AgentSession` | Add to public interface in `@gsd/agent-core/index.ts` | -| `buildSessionContext()` on `SessionManager` | Used by SF code, not publicly exported | Evaluate: re-export from `@gsd/agent-core` or remove dependency | +| Internal-path import of `AgentSessionEvent` | `src/web/bridge-service.ts` | Import from `@sf/agent-core` public export | +| `clearQueue()` not in typed public API | `AgentSession` | Add to public interface in `@sf/agent-core/index.ts` | +| `buildSessionContext()` on `SessionManager` | Used by SF code, not publicly exported | Evaluate: re-export from `@sf/agent-core` or remove dependency | | Deprecated `session_switch`, `session_fork`, `session_directory` usage | 2+ files in `pi-coding-agent` | Migrate to `session_start` with `reason` field (required for v0.65.0 compat) — can be done as part of or after clean seam work | --- @@ -222,9 +222,9 @@ Steps 2-5 are scoped to known files. No archaeology required. ### Positive - Pi updates are scoped: type errors from a pi update surface only in the two new SF packages, not scattered across mixed source -- The module system enforces the boundary: a pi file importing `@gsd/agent-core` is a compiler error, not a convention violation +- The module system enforces the boundary: a pi file importing `@sf/agent-core` is a compiler error, not a convention violation - Phase 2 (moving pi packages to npm) becomes a package.json change rather than a file archaeology project -- Headless/RPC consumers can depend on `@gsd/agent-core` without pulling in the TUI layer +- Headless/RPC consumers can depend on `@sf/agent-core` without pulling in the TUI layer ### Negative @@ -235,24 +235,24 @@ Steps 2-5 are scoped to known files. No archaeology required. ### Neutral - End-user install experience (`npm install -g sf-run@latest`) is unchanged -- Extension authors see no change — the extension API surface remains in `@gsd/pi-coding-agent` +- Extension authors see no change — the extension API surface remains in `@sf/pi-coding-agent` - SF packages continue to use pi types directly — no new abstraction layer --- ## Alternatives Considered -### Single `@gsd/agent` package +### Single `@sf/agent` package Move everything into one package instead of two. Simpler dependency graph but creates a large package where session logic and TUI logic share a build unit. Rejected because headless/RPC use cases would pull in the TUI unnecessarily, and the two concerns have meaningfully different consumers. ### Directory convention within `pi-coding-agent` (no new packages) -Add a `src/gsd/` subdirectory inside `pi-coding-agent` to clearly mark SF files without creating new packages. Fastest to implement but the seam is a convention, not enforced by the module system. A future accidental cross-import would not be caught by the compiler. Rejected because the enforcement value of proper packages is worth the modest extra setup. +Add a `src/sf/` subdirectory inside `pi-coding-agent` to clearly mark SF files without creating new packages. Fastest to implement but the seam is a convention, not enforced by the module system. A future accidental cross-import would not be caught by the compiler. Rejected because the enforcement value of proper packages is worth the modest extra setup. ### Move to npm dependencies now (Phase 2 first) -Take `@mariozechner/pi-*` from npm and skip vendoring entirely. Blocked by `@gsd/native` imports baked into the vendored source, ~50 direct source modification commits, and the upstream extension API gap. Deferred to Phase 2. +Take `@mariozechner/pi-*` from npm and skip vendoring entirely. Blocked by `@sf/native` imports baked into the vendored source, ~50 direct source modification commits, and the upstream extension API gap. Deferred to Phase 2. --- @@ -261,11 +261,11 @@ Take `@mariozechner/pi-*` from npm and skip vendoring entirely. Blocked by `@gsd The migration should proceed in this order to maintain a working build at each step: 1. **Audit** — identify all imports of `pi-coding-agent` internal paths (non-index) and document them -2. **Create packages** — scaffold `gsd-agent-core` and `gsd-agent-modes` with `package.json` and empty `index.ts` +2. **Create packages** — scaffold `sf-agent-core` and `sf-agent-modes` with `package.json` and empty `index.ts` 3. **Move files in batches** — start with leaf files (no downstream dependents within pi-coding-agent), work toward `agent-session.ts` last 4. **Fix imports incrementally** — TypeScript will identify broken imports after each batch 5. **Update extension loader** — add new packages to virtual module map 6. **Update build script** — insert new packages in dependency order -7. **Verify** — full build, existing tests pass, `gsd --version` works +7. **Verify** — full build, existing tests pass, `sf --version` works The pi update to v0.67.2 (and the deprecated API migration) can be done as a follow-on once the clean seam is in place, since that work will be dramatically simpler with the new structure. diff --git a/docs/dev/FILE-SYSTEM-MAP.md b/docs/dev/FILE-SYSTEM-MAP.md index f55efd838..47495aaca 100644 --- a/docs/dev/FILE-SYSTEM-MAP.md +++ b/docs/dev/FILE-SYSTEM-MAP.md @@ -84,7 +84,7 @@ | src/headless-events.ts | Headless Mode | Event classification, terminal detection, idle timeouts | | src/headless-query.ts | Headless Mode, CLI | Read-only snapshot query (state, dispatch preview, costs) | | src/headless-ui.ts | Headless Mode | Extension UI auto-response, progress formatting | -| src/headless.ts | Headless Mode | Orchestrator for /gsd subcommands without TUI via RPC | +| src/headless.ts | Headless Mode | Orchestrator for /sf subcommands without TUI via RPC | | src/help-text.ts | CLI | Generates help text for all subcommands | | src/loader.ts | Loader/Bootstrap | Fast-path startup, extension discovery/validation, env setup | | src/logo.ts | CLI | ASCII logo rendering for welcome screen and loader | @@ -464,94 +464,94 @@ | File | System Label(s) | Description | |------|-----------------|-------------| -| gsd/index.ts | SF Workflow | Main SF extension bootstrap and registration | -| gsd/auto.ts | Auto Engine | Automatic workflow execution and loop management | -| gsd/auto-dashboard.ts | Auto Engine, Web Mode | Real-time dashboard for auto-run progress | -| gsd/auto-worktree.ts | Auto Engine, Worktree | Automatic worktree creation and branch management | -| gsd/auto-recovery.ts | Auto Engine | Recovery for crashed/stalled workflows | -| gsd/auto-start.ts | Auto Engine | Initialization sequence for automatic execution | -| gsd/auto-worktree-sync.ts | Auto Engine, Worktree | State sync between worktrees and main | -| gsd/auto-model-selection.ts | Auto Engine, Model System | Intelligent LLM model routing | -| gsd/auto-direct-dispatch.ts | Auto Engine | Direct command dispatching without planning | -| gsd/auto-dispatch.ts | Auto Engine | Task queueing and priority-based dispatch | -| gsd/auto-timeout-recovery.ts | Auto Engine | Timeout handling and recovery | -| gsd/auto-post-unit.ts | Auto Engine | Post-unit milestone completion processing | -| gsd/auto-unit-closeout.ts | Auto Engine | Unit finalization and archiving | -| gsd/auto-verification.ts | Auto Engine | Post-execution verification | -| gsd/auto-timers.ts | Auto Engine | Timeout and deadline management | -| gsd/auto-loop.ts | Auto Engine, State Machine | Execution loop state and cycle management | -| gsd/auto-supervisor.ts | Auto Engine | Supervision and oversight of autonomous runs | -| gsd/auto-budget.ts | Auto Engine | Token/cost budgeting and tracking | -| gsd/auto-observability.ts | Auto Engine | Observability hooks and telemetry | -| gsd/auto-tool-tracking.ts | Auto Engine | Tool usage instrumentation | -| gsd/doctor.ts | Doctor/Diagnostics | Health check and system diagnostics | -| gsd/doctor-checks.ts | Doctor/Diagnostics | Individual diagnostic checks | -| gsd/doctor-providers.ts | Doctor/Diagnostics | Diagnostic data source providers | -| gsd/doctor-format.ts | Doctor/Diagnostics | Diagnostic output formatting | -| gsd/state.ts | State Machine | Milestone and workflow state management | -| gsd/history.ts | State Machine | State history and versioning | -| gsd/json-persistence.ts | State Machine | JSON-based persistence layer | -| gsd/memory-store.ts | State Machine | In-memory state storage | -| gsd/reactive-graph.ts | State Machine | Reactive dependency graph for state | -| gsd/routing-history.ts | State Machine | History of routing decisions | -| gsd/cache.ts | State Machine | Caching layer for performance | -| gsd/model-router.ts | Model System | LLM model selection and routing logic | -| gsd/worktree.ts | Worktree | Worktree creation and management | -| gsd/worktree-manager.ts | Worktree | Higher-level worktree orchestration | -| gsd/worktree-resolver.ts | Worktree | Worktree path and reference resolution | -| gsd/unit-runtime.ts | Auto Engine | Unit-level execution runtime | -| gsd/activity-log.ts | SF Workflow | Activity tracking and logging | -| gsd/debug-logger.ts | SF Workflow | Debug output and verbose logging | -| gsd/commands.ts | Commands | Main command dispatcher | -| gsd/commands-handlers.ts | Commands | Command-specific handlers | -| gsd/commands-bootstrap.ts | Commands | Bootstrap and initialization commands | -| gsd/commands-config.ts | Commands, Config | Configuration management commands | -| gsd/commands-extensions.ts | Commands, Extensions | Extension discovery and management | -| gsd/commands-inspect.ts | Commands, Doctor/Diagnostics | Database and state inspection tools | -| gsd/commands-logs.ts | Commands | Log viewing and filtering | -| gsd/commands-workflow-templates.ts | Commands, SF Workflow | Workflow template management | -| gsd/commands-cmux.ts | Commands, CMux | Tmux/cmux integration commands | -| gsd/exit-command.ts | Commands | Exit and cleanup commands | -| gsd/undo.ts | Commands | Undo and rollback functionality | -| gsd/kill.ts | Commands | Process termination and cleanup | -| gsd/worktree-command.ts | Commands, Worktree | Worktree subcommands | -| gsd/namespaced-resolver.ts | SF Workflow | Namespace and scoped resource resolution | -| gsd/error-utils.ts | SF Workflow | Error handling and formatting | -| gsd/errors.ts | SF Workflow | Error type definitions | -| gsd/diff-context.ts | SF Workflow | Diff-based context extraction | -| gsd/memory-extractor.ts | SF Workflow | Memory and context extraction from state | -| gsd/structured-data-formatter.ts | SF Workflow | Structured output formatting | -| gsd/export-html.ts | SF Workflow | HTML export of milestone reports | -| gsd/reports.ts | SF Workflow | Report generation and summaries | -| gsd/notifications.ts | SF Workflow | User notification and messaging | -| gsd/triage-ui.ts | SF Workflow | Triage interface for issue categorization | -| gsd/guided-flow.ts | SF Workflow | User-guided workflow orchestration | -| gsd/env-utils.ts | SF Workflow | Environment variable utilities | -| gsd/git-constants.ts | SF Workflow | Git-related constants and paths | -| gsd/milestone-id-utils.ts | SF Workflow | Milestone ID generation and parsing | -| gsd/resource-version.ts | SF Workflow | Resource versioning helpers | -| gsd/atomic-write.ts | SF Workflow | Atomic file write operations | -| gsd/captures.ts | SF Workflow | Artifact capture and storage | -| gsd/changelog.ts | SF Workflow | Changelog generation | -| gsd/claude-import.ts | SF Workflow | Claude API/resource importing | -| gsd/collision-diagnostics.ts | Doctor/Diagnostics | Collision detection and diagnostics | -| gsd/prompt-loader.ts | SF Workflow | Prompt template loading | -| gsd/file-watcher.ts | SF Workflow | File system change monitoring | -| gsd/parallel-eligibility.ts | SF Workflow | Parallel execution eligibility checks | -| gsd/plugin-importer.ts | SF Workflow, Extensions | Custom plugin/extension importing | -| gsd/verification-gate.ts | SF Workflow | Pre-execution verification checks | -| gsd/preference-models.ts | Config, Model System | Model preference configuration | -| gsd/preferences-skills.ts | Config, Skills | Skill preference configuration | -| gsd/post-unit-hooks.ts | SF Workflow | Post-unit execution hooks | -| gsd/skill-telemetry.ts | Skills | Skill usage and performance telemetry | -| gsd/bootstrap/* | SF Workflow, Loader/Bootstrap | Extension initialization and hook registration | -| gsd/auto/* | Auto Engine | Auto-execution engine components | -| gsd/commands/* | Commands | Command routing and handling | -| gsd/templates/* | SF Workflow | Output templates and formatters | -| gsd/prompts/* | SF Workflow | System prompts and instructions | -| gsd/workflow-templates/* | SF Workflow | Workflow starter templates and registry | -| gsd/skills/* | Skills | Integrated skill configurations | -| gsd/migrate/* | Migration | Data migration and upgrade tools | +| sf/index.ts | SF Workflow | Main SF extension bootstrap and registration | +| sf/auto.ts | Auto Engine | Automatic workflow execution and loop management | +| sf/auto-dashboard.ts | Auto Engine, Web Mode | Real-time dashboard for auto-run progress | +| sf/auto-worktree.ts | Auto Engine, Worktree | Automatic worktree creation and branch management | +| sf/auto-recovery.ts | Auto Engine | Recovery for crashed/stalled workflows | +| sf/auto-start.ts | Auto Engine | Initialization sequence for automatic execution | +| sf/auto-worktree-sync.ts | Auto Engine, Worktree | State sync between worktrees and main | +| sf/auto-model-selection.ts | Auto Engine, Model System | Intelligent LLM model routing | +| sf/auto-direct-dispatch.ts | Auto Engine | Direct command dispatching without planning | +| sf/auto-dispatch.ts | Auto Engine | Task queueing and priority-based dispatch | +| sf/auto-timeout-recovery.ts | Auto Engine | Timeout handling and recovery | +| sf/auto-post-unit.ts | Auto Engine | Post-unit milestone completion processing | +| sf/auto-unit-closeout.ts | Auto Engine | Unit finalization and archiving | +| sf/auto-verification.ts | Auto Engine | Post-execution verification | +| sf/auto-timers.ts | Auto Engine | Timeout and deadline management | +| sf/auto-loop.ts | Auto Engine, State Machine | Execution loop state and cycle management | +| sf/auto-supervisor.ts | Auto Engine | Supervision and oversight of autonomous runs | +| sf/auto-budget.ts | Auto Engine | Token/cost budgeting and tracking | +| sf/auto-observability.ts | Auto Engine | Observability hooks and telemetry | +| sf/auto-tool-tracking.ts | Auto Engine | Tool usage instrumentation | +| sf/doctor.ts | Doctor/Diagnostics | Health check and system diagnostics | +| sf/doctor-checks.ts | Doctor/Diagnostics | Individual diagnostic checks | +| sf/doctor-providers.ts | Doctor/Diagnostics | Diagnostic data source providers | +| sf/doctor-format.ts | Doctor/Diagnostics | Diagnostic output formatting | +| sf/state.ts | State Machine | Milestone and workflow state management | +| sf/history.ts | State Machine | State history and versioning | +| sf/json-persistence.ts | State Machine | JSON-based persistence layer | +| sf/memory-store.ts | State Machine | In-memory state storage | +| sf/reactive-graph.ts | State Machine | Reactive dependency graph for state | +| sf/routing-history.ts | State Machine | History of routing decisions | +| sf/cache.ts | State Machine | Caching layer for performance | +| sf/model-router.ts | Model System | LLM model selection and routing logic | +| sf/worktree.ts | Worktree | Worktree creation and management | +| sf/worktree-manager.ts | Worktree | Higher-level worktree orchestration | +| sf/worktree-resolver.ts | Worktree | Worktree path and reference resolution | +| sf/unit-runtime.ts | Auto Engine | Unit-level execution runtime | +| sf/activity-log.ts | SF Workflow | Activity tracking and logging | +| sf/debug-logger.ts | SF Workflow | Debug output and verbose logging | +| sf/commands.ts | Commands | Main command dispatcher | +| sf/commands-handlers.ts | Commands | Command-specific handlers | +| sf/commands-bootstrap.ts | Commands | Bootstrap and initialization commands | +| sf/commands-config.ts | Commands, Config | Configuration management commands | +| sf/commands-extensions.ts | Commands, Extensions | Extension discovery and management | +| sf/commands-inspect.ts | Commands, Doctor/Diagnostics | Database and state inspection tools | +| sf/commands-logs.ts | Commands | Log viewing and filtering | +| sf/commands-workflow-templates.ts | Commands, SF Workflow | Workflow template management | +| sf/commands-cmux.ts | Commands, CMux | Tmux/cmux integration commands | +| sf/exit-command.ts | Commands | Exit and cleanup commands | +| sf/undo.ts | Commands | Undo and rollback functionality | +| sf/kill.ts | Commands | Process termination and cleanup | +| sf/worktree-command.ts | Commands, Worktree | Worktree subcommands | +| sf/namespaced-resolver.ts | SF Workflow | Namespace and scoped resource resolution | +| sf/error-utils.ts | SF Workflow | Error handling and formatting | +| sf/errors.ts | SF Workflow | Error type definitions | +| sf/diff-context.ts | SF Workflow | Diff-based context extraction | +| sf/memory-extractor.ts | SF Workflow | Memory and context extraction from state | +| sf/structured-data-formatter.ts | SF Workflow | Structured output formatting | +| sf/export-html.ts | SF Workflow | HTML export of milestone reports | +| sf/reports.ts | SF Workflow | Report generation and summaries | +| sf/notifications.ts | SF Workflow | User notification and messaging | +| sf/triage-ui.ts | SF Workflow | Triage interface for issue categorization | +| sf/guided-flow.ts | SF Workflow | User-guided workflow orchestration | +| sf/env-utils.ts | SF Workflow | Environment variable utilities | +| sf/git-constants.ts | SF Workflow | Git-related constants and paths | +| sf/milestone-id-utils.ts | SF Workflow | Milestone ID generation and parsing | +| sf/resource-version.ts | SF Workflow | Resource versioning helpers | +| sf/atomic-write.ts | SF Workflow | Atomic file write operations | +| sf/captures.ts | SF Workflow | Artifact capture and storage | +| sf/changelog.ts | SF Workflow | Changelog generation | +| sf/claude-import.ts | SF Workflow | Claude API/resource importing | +| sf/collision-diagnostics.ts | Doctor/Diagnostics | Collision detection and diagnostics | +| sf/prompt-loader.ts | SF Workflow | Prompt template loading | +| sf/file-watcher.ts | SF Workflow | File system change monitoring | +| sf/parallel-eligibility.ts | SF Workflow | Parallel execution eligibility checks | +| sf/plugin-importer.ts | SF Workflow, Extensions | Custom plugin/extension importing | +| sf/verification-gate.ts | SF Workflow | Pre-execution verification checks | +| sf/preference-models.ts | Config, Model System | Model preference configuration | +| sf/preferences-skills.ts | Config, Skills | Skill preference configuration | +| sf/post-unit-hooks.ts | SF Workflow | Post-unit execution hooks | +| sf/skill-telemetry.ts | Skills | Skill usage and performance telemetry | +| sf/bootstrap/* | SF Workflow, Loader/Bootstrap | Extension initialization and hook registration | +| sf/auto/* | Auto Engine | Auto-execution engine components | +| sf/commands/* | Commands | Command routing and handling | +| sf/templates/* | SF Workflow | Output templates and formatters | +| sf/prompts/* | SF Workflow | System prompts and instructions | +| sf/workflow-templates/* | SF Workflow | Workflow starter templates and registry | +| sf/skills/* | Skills | Integrated skill configurations | +| sf/migrate/* | Migration | Data migration and upgrade tools | ### Other Extensions @@ -658,7 +658,7 @@ | react-best-practices/ | Skills | React development patterns (62 files) | | userinterface-wiki/ | Skills | UI/UX guidelines and component reference (155 files) | | create-skill/ | Skills | Skill creation scaffolding and templates (25 files) | -| create-gsd-extension/ | Skills, Extensions | SF extension scaffolding (22 files) | +| create-sf-extension/ | Skills, Extensions | SF extension scaffolding (22 files) | | code-optimizer/ | Skills | Performance optimization techniques (16 files) | | agent-browser/ | Skills, Browser Tools | Browser automation guidance (11 files) | | github-workflows/ | Skills | GitHub Actions workflow patterns (10 files) | @@ -684,64 +684,64 @@ |------|-----------------|-------------| | web/app/layout.tsx | Web UI | Root Next.js layout with theme provider and font | | web/app/page.tsx | Web UI | Entry page loading GSDAppShell | -| web/components/gsd/app-shell.tsx | Web UI | Main app shell — sidebar, panels, terminal, commands | -| web/components/gsd/sidebar.tsx | Web UI | Multi-panel sidebar with milestone explorer | -| web/components/gsd/status-bar.tsx | Web UI | Status bar with workspace state and metrics | +| 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 | ### Main Views | File | System Label(s) | Description | |------|-----------------|-------------| -| web/components/gsd/dashboard.tsx | Web UI | Dashboard with workflow actions and metrics | -| web/components/gsd/chat-mode.tsx | Web UI | Chat interface for agent interaction | -| web/components/gsd/projects-view.tsx | Web UI | Project browser and selector | -| web/components/gsd/files-view.tsx | Web UI | File browser and explorer | -| web/components/gsd/activity-view.tsx | Web UI | Activity log and history view | -| web/components/gsd/roadmap.tsx | Web UI, SF Workflow | Milestone roadmap visualization | -| web/components/gsd/visualizer-view.tsx | Web UI, Doctor/Diagnostics | Workflow visualization | -| web/components/gsd/project-welcome.tsx | Web UI | Welcome screen for new projects | -| web/components/gsd/knowledge-captures-panel.tsx | Web UI | Knowledge and capture management | +| web/components/sf/dashboard.tsx | Web UI | Dashboard with workflow actions and metrics | +| web/components/sf/chat-mode.tsx | Web UI | Chat interface for agent interaction | +| web/components/sf/projects-view.tsx | Web UI | Project browser and selector | +| web/components/sf/files-view.tsx | Web UI | File browser and explorer | +| web/components/sf/activity-view.tsx | Web UI | Activity log and history view | +| web/components/sf/roadmap.tsx | Web UI, SF Workflow | Milestone roadmap visualization | +| web/components/sf/visualizer-view.tsx | Web UI, Doctor/Diagnostics | Workflow visualization | +| web/components/sf/project-welcome.tsx | Web UI | Welcome screen for new projects | +| web/components/sf/knowledge-captures-panel.tsx | Web UI | Knowledge and capture management | ### Terminal | File | System Label(s) | Description | |------|-----------------|-------------| -| web/components/gsd/terminal.tsx | Web UI | Terminal widget with input mode handling | -| web/components/gsd/shell-terminal.tsx | Web UI | Shell terminal with PTY integration | -| web/components/gsd/main-session-terminal.tsx | Web UI | Main session terminal display | -| web/components/gsd/dual-terminal.tsx | Web UI | Side-by-side terminal layout | +| web/components/sf/terminal.tsx | Web UI | Terminal widget with input mode handling | +| web/components/sf/shell-terminal.tsx | Web UI | Shell terminal with PTY integration | +| web/components/sf/main-session-terminal.tsx | Web UI | Main session terminal display | +| web/components/sf/dual-terminal.tsx | Web UI | Side-by-side terminal layout | ### Commands & Dialogs | File | System Label(s) | Description | |------|-----------------|-------------| -| web/components/gsd/command-surface.tsx | Web UI, Commands | Command palette and slash command dispatcher | -| web/components/gsd/remaining-command-panels.tsx | Web UI, Commands | History, undo, export, cleanup panels | -| web/components/gsd/diagnostics-panels.tsx | Web UI, Doctor/Diagnostics | Doctor, forensics, skill health panels | -| web/components/gsd/settings-panels.tsx | Web UI, Config | Settings and preferences panels | -| web/components/gsd/guided-dialog.tsx | Web UI | Generic guided dialog component | -| web/components/gsd/update-banner.tsx | Web UI | Update notification banner | -| web/components/gsd/scope-badge.tsx | Web UI | Scope badge indicator | -| web/components/gsd/loading-skeletons.tsx | Web UI | Loading skeleton placeholders | -| web/components/gsd/code-editor.tsx | Web UI | Code editor display component | -| web/components/gsd/file-content-viewer.tsx | Web UI | File content viewer and previewer | -| web/components/gsd/focused-panel.tsx | Web UI | Focused panel layout component | +| web/components/sf/command-surface.tsx | Web UI, Commands | Command palette and slash command dispatcher | +| web/components/sf/remaining-command-panels.tsx | Web UI, Commands | History, undo, export, cleanup panels | +| web/components/sf/diagnostics-panels.tsx | Web UI, Doctor/Diagnostics | Doctor, forensics, skill health panels | +| web/components/sf/settings-panels.tsx | Web UI, Config | Settings and preferences panels | +| web/components/sf/guided-dialog.tsx | Web UI | Generic guided dialog component | +| web/components/sf/update-banner.tsx | Web UI | Update notification banner | +| web/components/sf/scope-badge.tsx | Web UI | Scope badge indicator | +| web/components/sf/loading-skeletons.tsx | Web UI | Loading skeleton placeholders | +| web/components/sf/code-editor.tsx | Web UI | Code editor display component | +| web/components/sf/file-content-viewer.tsx | Web UI | File content viewer and previewer | +| web/components/sf/focused-panel.tsx | Web UI | Focused panel layout component | ### Onboarding | File | System Label(s) | Description | |------|-----------------|-------------| -| web/components/gsd/onboarding-gate.tsx | Web UI, Onboarding | Gate and orchestration for onboarding flow | -| web/components/gsd/onboarding/step-welcome.tsx | Web UI, Onboarding | Welcome step | -| web/components/gsd/onboarding/step-mode.tsx | Web UI, Onboarding | User mode selection step | -| web/components/gsd/onboarding/step-provider.tsx | Web UI, Onboarding | LLM provider selection step | -| web/components/gsd/onboarding/step-authenticate.tsx | Web UI, Onboarding, Auth/OAuth | Authentication step | -| web/components/gsd/onboarding/step-dev-root.tsx | Web UI, Onboarding | Dev root directory selection step | -| web/components/gsd/onboarding/step-project.tsx | Web UI, Onboarding | Project selection step | -| web/components/gsd/onboarding/step-remote.tsx | Web UI, Onboarding | Remote configuration step | -| web/components/gsd/onboarding/step-optional.tsx | Web UI, Onboarding | Optional settings step | -| web/components/gsd/onboarding/step-ready.tsx | Web UI, Onboarding | Ready confirmation step | -| web/components/gsd/onboarding/wizard-stepper.tsx | Web UI, Onboarding | Stepper progress indicator | +| web/components/sf/onboarding-gate.tsx | Web UI, Onboarding | Gate and orchestration for onboarding flow | +| web/components/sf/onboarding/step-welcome.tsx | Web UI, Onboarding | Welcome step | +| web/components/sf/onboarding/step-mode.tsx | Web UI, Onboarding | User mode selection step | +| web/components/sf/onboarding/step-provider.tsx | Web UI, Onboarding | LLM provider selection step | +| web/components/sf/onboarding/step-authenticate.tsx | Web UI, Onboarding, Auth/OAuth | Authentication step | +| web/components/sf/onboarding/step-dev-root.tsx | Web UI, Onboarding | Dev root directory selection step | +| web/components/sf/onboarding/step-project.tsx | Web UI, Onboarding | Project selection step | +| web/components/sf/onboarding/step-remote.tsx | Web UI, Onboarding | Remote configuration step | +| web/components/sf/onboarding/step-optional.tsx | Web UI, Onboarding | Optional settings step | +| web/components/sf/onboarding/step-ready.tsx | Web UI, Onboarding | Ready confirmation step | +| web/components/sf/onboarding/wizard-stepper.tsx | Web UI, Onboarding | Stepper progress indicator | ### API Routes @@ -792,7 +792,7 @@ | File | System Label(s) | Description | |------|-----------------|-------------| | web/lib/auth.ts | Auth/OAuth | Client-side auth token management from URL fragment | -| web/lib/gsd-workspace-store.tsx | State Machine | Global workspace state store with external store | +| web/lib/sf-workspace-store.tsx | State Machine | Global workspace state store with external store | | web/lib/project-store-manager.tsx | State Machine | Multi-project store manager with SSE lifecycle | | web/lib/shutdown-gate.ts | State Machine | Graceful shutdown coordination | | web/lib/browser-slash-command-dispatch.ts | Commands | Slash command dispatch | @@ -827,8 +827,8 @@ | File | System Label(s) | Description | |------|-----------------|-------------| | vscode-extension/src/extension.ts | VS Code Extension | Extension activation, client management, command registration | -| vscode-extension/src/gsd-client.ts | VS Code Extension, MCP Server/Client | RPC client for SF agent communication | -| vscode-extension/src/chat-participant.ts | VS Code Extension | Chat participant for @gsd command | +| vscode-extension/src/sf-client.ts | VS Code Extension, MCP Server/Client | RPC client for SF agent communication | +| vscode-extension/src/chat-participant.ts | VS Code Extension | Chat participant for @sf command | | vscode-extension/src/sidebar.ts | VS Code Extension | Sidebar webview provider with status display | --- @@ -865,7 +865,7 @@ | native/crates/engine/src/ps.rs | Native/Rust Tools | Cross-platform process tree management | | native/crates/engine/src/clipboard.rs | Native/Rust Tools | Clipboard read/write for text and images | | native/crates/engine/src/json_parse.rs | Text Processing, Native/Rust Tools | Streaming JSON parser with partial recovery | -| native/crates/engine/src/gsd_parser.rs | SF Workflow, Native/Rust Tools | .gsd/ directory file parser (markdown, frontmatter) | +| native/crates/engine/src/gsd_parser.rs | SF Workflow, Native/Rust Tools | .sf/ directory file parser (markdown, frontmatter) | | native/crates/engine/src/ttsr.rs | TTSR, Native/Rust Tools | TTSR regex engine with compiled RegexSet | | native/crates/engine/src/stream_process.rs | Text Processing, Native/Rust Tools | Bash stream processor (UTF-8, ANSI strip, binary) | | native/crates/engine/src/xxhash.rs | Native/Rust Tools | xxHash32 for hashline edit tool | @@ -948,10 +948,10 @@ | scripts/check-skill-references.mjs | Build System, Skills | Skill reference validator | | scripts/preview-dashboard.ts | Web Mode | Dashboard preview server | | scripts/ci_monitor.cjs | Build System | CI monitoring dashboard | -| scripts/recover-gsd-1364.sh | Build System, Migration | Recovery script for issue #1364 | -| scripts/recover-gsd-1364.ps1 | Build System, Migration | Recovery script for issue #1364 (PowerShell) | -| scripts/recover-gsd-1668.sh | Build System, Migration | Recovery script for issue #1668 | -| scripts/recover-gsd-1668.ps1 | Build System, Migration | Recovery script for issue #1668 (PowerShell) | +| scripts/recover-sf-1364.sh | Build System, Migration | Recovery script for issue #1364 | +| scripts/recover-sf-1364.ps1 | Build System, Migration | Recovery script for issue #1364 (PowerShell) | +| scripts/recover-sf-1668.sh | Build System, Migration | Recovery script for issue #1668 | +| scripts/recover-sf-1668.ps1 | Build System, Migration | Recovery script for issue #1668 (PowerShell) | --- @@ -967,44 +967,44 @@ Quick lookup: which files are part of each system? | **AST** | native/crates/ast/*, packages/native/src/ast/ | | **Async Jobs** | src/resources/extensions/async-jobs/* | | **Auth / OAuth** | pi-ai/src/utils/oauth/*, src/web/web-auth-storage.ts, core/auth-storage.ts, src/pi-migration.ts, aws-auth/index.ts, web/lib/auth.ts | -| **Auto Engine** | src/resources/extensions/gsd/auto*.ts, gsd/auto-loop.ts, gsd/auto-supervisor.ts, gsd/unit-runtime.ts | +| **Auto Engine** | src/resources/extensions/sf/auto*.ts, sf/auto-loop.ts, sf/auto-supervisor.ts, sf/unit-runtime.ts | | **Bg Shell** | src/resources/extensions/bg-shell/* | | **Browser Tools** | src/resources/extensions/browser-tools/* | | **Build System** | scripts/*, native/crates/engine/build.rs | | **CLI** | src/cli.ts, src/cli-web-branch.ts, src/help-text.ts, src/update*.ts, pi-coding-agent/src/cli.ts, src/worktree-cli.ts | | **CMux** | src/resources/extensions/cmux/index.ts | -| **Commands** | gsd/commands*.ts, gsd/exit-command.ts, gsd/undo.ts, gsd/kill.ts, pi-coding-agent/src/core/slash-commands.ts | +| **Commands** | sf/commands*.ts, sf/exit-command.ts, sf/undo.ts, sf/kill.ts, pi-coding-agent/src/core/slash-commands.ts | | **Compaction** | pi-coding-agent/src/core/compaction*.ts, core/compaction/* | | **Config** | src/app-paths.ts, src/models-resolver.ts, src/remote-questions-config.ts, src/wizard.ts, core/defaults.ts, core/constants.ts, config.ts | | **Context7** | src/resources/extensions/context7/index.ts | -| **Doctor / Diagnostics** | gsd/doctor*.ts, gsd/collision-diagnostics.ts, core/diagnostics.ts, web/lib/diagnostics-types.ts, web/app/api/doctor/*, forensics/* | -| **Event System** | pi-coding-agent/src/core/event-bus.ts, gsd/auto-observability.ts | +| **Doctor / Diagnostics** | sf/doctor*.ts, sf/collision-diagnostics.ts, core/diagnostics.ts, web/lib/diagnostics-types.ts, web/app/api/doctor/*, forensics/* | +| **Event System** | pi-coding-agent/src/core/event-bus.ts, sf/auto-observability.ts | | **Extension Registry** | src/extension-discovery.ts, src/extension-registry.ts, src/bundled-extension-paths.ts | | **Extensions** | pi-coding-agent/src/core/extensions/*, src/resource-loader.ts | | **File Search** | native/crates/engine/src/grep.rs, glob.rs, fd.rs, fs_cache.rs, packages/native/src/grep/*, fd/*, core/tools/grep.ts, find.ts | -| **SF Workflow** | src/resources/extensions/gsd/* (non-auto), gsd/reports.ts, gsd/notifications.ts, gsd/prompts/*, gsd/workflow-templates/* | +| **SF Workflow** | src/resources/extensions/sf/* (non-auto), sf/reports.ts, sf/notifications.ts, sf/prompts/*, sf/workflow-templates/* | | **Google Search** | src/resources/extensions/google-search/index.ts | | **Headless Mode** | src/headless*.ts | | **Image Processing** | native/crates/engine/src/image.rs, packages/native/src/image/*, utils/image-*.ts, web/lib/image-utils.ts | | **Integration Tests** | tests/**/* | -| **Loader / Bootstrap** | src/loader.ts, src/resource-loader.ts, src/tool-bootstrap.ts, src/bundled-resource-path.ts, gsd/bootstrap/* | +| **Loader / Bootstrap** | src/loader.ts, src/resource-loader.ts, src/tool-bootstrap.ts, src/bundled-resource-path.ts, sf/bootstrap/* | | **LSP** | pi-coding-agent/src/core/lsp/* | | **Mac Tools** | src/resources/extensions/mac-tools/* | -| **MCP Server/Client** | src/mcp-server.ts, src/resources/extensions/mcp-client/index.ts, vscode-extension/src/gsd-client.ts, modes/rpc/* | +| **MCP Server/Client** | src/mcp-server.ts, src/resources/extensions/mcp-client/index.ts, vscode-extension/src/sf-client.ts, modes/rpc/* | | **Memory Extension** | pi-coding-agent/src/resources/extensions/memory/* | -| **Migration** | gsd/migrate/*, src/pi-migration.ts, pi-coding-agent/src/migrations.ts, scripts/recover-*.sh | +| **Migration** | sf/migrate/*, src/pi-migration.ts, pi-coding-agent/src/migrations.ts, scripts/recover-*.sh | | **Modes** | pi-coding-agent/src/modes/* | -| **Model System** | pi-coding-agent/src/core/model-*.ts, pi-ai/src/models*.ts, pi-ai/src/api-registry.ts, gsd/model-router.ts | +| **Model System** | pi-coding-agent/src/core/model-*.ts, pi-ai/src/models*.ts, pi-ai/src/api-registry.ts, sf/model-router.ts | | **Native / Rust Tools** | native/crates/engine/src/* | | **Node.js Bindings** | packages/native/src/* | -| **Onboarding** | src/onboarding.ts, src/wizard.ts, web/components/gsd/onboarding/*, web/app/api/onboarding/* | +| **Onboarding** | src/onboarding.ts, src/wizard.ts, web/components/sf/onboarding/*, web/app/api/onboarding/* | | **Permissions** | core/extensions/project-trust.ts, core/auth-storage.ts | | **Remote Questions** | src/resources/extensions/remote-questions/* | | **Search the Web** | src/resources/extensions/search-the-web/* | | **Session Management** | pi-coding-agent/src/core/session-manager.ts, core/settings-manager.ts, web/app/api/session/* | -| **Skills** | src/resources/skills/*, gsd/skill-telemetry.ts, gsd/preferences-skills.ts, core/skills.ts | +| **Skills** | src/resources/skills/*, sf/skill-telemetry.ts, sf/preferences-skills.ts, core/skills.ts | | **Slash Commands** | src/resources/extensions/slash-commands/* | -| **State Machine** | gsd/state.ts, gsd/history.ts, gsd/json-persistence.ts, gsd/memory-store.ts, gsd/reactive-graph.ts, core/agent-session.ts, web/lib/gsd-workspace-store.tsx | +| **State Machine** | sf/state.ts, sf/history.ts, sf/json-persistence.ts, sf/memory-store.ts, sf/reactive-graph.ts, core/agent-session.ts, web/lib/sf-workspace-store.tsx | | **Studio App** | studio/* | | **Subagent** | src/resources/extensions/subagent/*, src/resources/agents/* | | **Syntax Highlighting** | native/crates/engine/src/highlight.rs, packages/native/src/highlight/* | @@ -1017,4 +1017,4 @@ Quick lookup: which files are part of each system? | **VS Code Extension** | vscode-extension/src/* | | **Web Mode** | src/web/*.ts, src/web-mode.ts | | **Web UI** | web/app/*.tsx, web/components/*, web/hooks/*, web/lib/* | -| **Worktree** | src/worktree-cli.ts, src/worktree-name-gen.ts, gsd/worktree*.ts, tests/repro-worktree-bug/* | +| **Worktree** | src/worktree-cli.ts, src/worktree-name-gen.ts, sf/worktree*.ts, tests/repro-worktree-bug/* | diff --git a/docs/dev/PRD-branchless-worktree-architecture.md b/docs/dev/PRD-branchless-worktree-architecture.md index 99a752a04..7a124e00a 100644 --- a/docs/dev/PRD-branchless-worktree-architecture.md +++ b/docs/dev/PRD-branchless-worktree-architecture.md @@ -13,7 +13,7 @@ SF's auto-mode is unreliable. Users experience: 1. **Infinite loop detection failures** — the agent writes planning artifacts on slice branches that become invisible after branch switching, causing `verifyExpectedArtifact()` to fail repeatedly. Auto-mode burns budget retrying the same unit 3-6 times before hard-stopping. This is the #1 user complaint. -2. **State corruption across branches** — `.gsd/` planning artifacts (roadmaps, plans, decisions) are gitignored but branch-specific. Multiple branches sharing a single `.gsd/` directory clobber each other's state. Users see wrong milestones marked complete, wrong roadmaps loaded, and auto-mode starting from the wrong phase. +2. **State corruption across branches** — `.sf/` planning artifacts (roadmaps, plans, decisions) are gitignored but branch-specific. Multiple branches sharing a single `.sf/` directory clobber each other's state. Users see wrong milestones marked complete, wrong roadmaps loaded, and auto-mode starting from the wrong phase. 3. **Excessive complexity** — 770+ lines of merge, conflict resolution, branch switching, and self-healing code exist solely to manage slice branches inside worktrees. This code has required 15+ bug fixes across versions and remains the primary source of auto-mode failures. @@ -28,10 +28,10 @@ Auto-mode uses git worktrees for isolation and sequential commits for history. N | Criterion | Measurement | |-----------|-------------| | Zero loop detection failures from branch visibility | No `verifyExpectedArtifact()` failures caused by branch mismatch in 50 consecutive auto-mode runs | -| Zero `.gsd/` state corruption | Manual worktrees created via `git worktree add` have correct `.gsd/` state without any SF-specific initialization | +| Zero `.sf/` state corruption | Manual worktrees created via `git worktree add` have correct `.sf/` state without any SF-specific initialization | | Code deletion | Net removal of ≥500 lines of merge/conflict/branch-switching code | | Test simplification | Removal or simplification of ≥6 merge-specific test files | -| Backwards compatibility | Existing projects with `gsd/M001/S01` slice branches continue to work (read-only; new work uses new model) | +| Backwards compatibility | Existing projects with `sf/M001/S01` slice branches continue to work (read-only; new work uses new model) | | No new git primitives | The implementation uses only: worktrees, commits, squash-merge. No new branch types, merge strategies, or conflict resolution. | ## Non-Goals @@ -47,10 +47,10 @@ Auto-mode uses git worktrees for isolation and sequential commits for history. N ``` main - └─ milestone/M001 (worktree at .gsd/worktrees/M001/) - ├─ gsd/M001/S01 (slice branch — code + .gsd/ artifacts) + └─ milestone/M001 (worktree at .sf/worktrees/M001/) + ├─ sf/M001/S01 (slice branch — code + .sf/ artifacts) │ └── merge --no-ff → milestone/M001 - ├─ gsd/M001/S02 + ├─ sf/M001/S02 │ └── merge --no-ff → milestone/M001 └── squash merge → main ``` @@ -74,11 +74,11 @@ Agent writes file → on slice branch → handleAgentEnd → auto-commit on slic | `worktree.ts` | ~40 lines | Slice branch delegates | | 11 test files | ~2000 lines | Merge/branch/worktree test coverage | -### `.gsd/` Tracking (Current — Contradictory) +### `.sf/` Tracking (Current — Contradictory) -- `.gitignore` line 52: `.gsd/` — ignores everything +- `.gitignore` line 52: `.sf/` — ignores everything - `smartStage()` lines 338-349: force-adds `SF_DURABLE_PATHS` — tracks milestones/, DECISIONS.md, PROJECT.md, REQUIREMENTS.md, QUEUE.md -- Result: `.gsd/milestones/` is partially tracked on some branches, fully ignored on others. The code fights the config. +- Result: `.sf/milestones/` is partially tracked on some branches, fully ignored on others. The code fights the config. ## Proposed Architecture @@ -86,7 +86,7 @@ Agent writes file → on slice branch → handleAgentEnd → auto-commit on slic ``` main - └─ milestone/M001 (worktree at .gsd/worktrees/M001/) + └─ milestone/M001 (worktree at .sf/worktrees/M001/) │ commit: feat(M001): context + roadmap commit: feat(M001/S01): research @@ -112,31 +112,31 @@ Agent writes file → on milestone branch → handleAgentEnd → auto-commit on → verifyExpectedArtifact → FILE FOUND (same branch) → persist completion → next dispatch ``` -### `.gsd/` Tracking (Proposed — Coherent) +### `.sf/` Tracking (Proposed — Coherent) **Tracked (travels with branch):** ``` -.gsd/milestones/**/*.md (except CONTINUE markers) -.gsd/milestones/**/*.json (META.json integration records) -.gsd/PROJECT.md -.gsd/DECISIONS.md -.gsd/REQUIREMENTS.md -.gsd/QUEUE.md +.sf/milestones/**/*.md (except CONTINUE markers) +.sf/milestones/**/*.json (META.json integration records) +.sf/PROJECT.md +.sf/DECISIONS.md +.sf/REQUIREMENTS.md +.sf/QUEUE.md ``` **Gitignored (ephemeral):** ``` -.gsd/auto.lock -.gsd/completed-units.json -.gsd/STATE.md -.gsd/metrics.json -.gsd/gsd.db -.gsd/activity/ -.gsd/runtime/ -.gsd/worktrees/ -.gsd/DISCUSSION-MANIFEST.json -.gsd/milestones/**/*-CONTINUE.md -.gsd/milestones/**/continue.md +.sf/auto.lock +.sf/completed-units.json +.sf/STATE.md +.sf/metrics.json +.sf/sf.db +.sf/activity/ +.sf/runtime/ +.sf/worktrees/ +.sf/DISCUSSION-MANIFEST.json +.sf/milestones/**/*-CONTINUE.md +.sf/milestones/**/continue.md ``` ### Why This Works @@ -144,7 +144,7 @@ Agent writes file → on milestone branch → handleAgentEnd → auto-commit on | Problem | How It's Solved | |---------|----------------| | Artifact invisibility after branch switch | No branch switching. Artifacts commit on the one branch. | -| `.gsd/` state clobbering | Artifacts tracked in git. Each branch carries its own `.gsd/`. `git worktree add` and `git checkout` give correct state. | +| `.sf/` state clobbering | Artifacts tracked in git. Each branch carries its own `.sf/`. `git worktree add` and `git checkout` give correct state. | | Merge conflict complexity | No merges within a worktree. Only merge is milestone→main (squash). | | Manual worktree initialization | Tracked artifacts are checked out with the branch. No SF-specific bootstrap needed. | | Dual isolation mode maintenance | Single mode: worktree. Branch-mode (`git.isolation: "branch"`) deprecated. | @@ -156,24 +156,24 @@ Agent writes file → on milestone branch → handleAgentEnd → auto-commit on **Goal:** Planning artifacts are tracked in git. `.gitignore` reflects reality. 1. Update `.gitignore`: - - Remove blanket `.gsd/` ignore + - Remove blanket `.sf/` ignore - Add explicit runtime-only ignores (see proposed list above) 2. Force-add existing planning artifacts on current branch: ``` - git add --force .gsd/milestones/ .gsd/PROJECT.md .gsd/DECISIONS.md .gsd/REQUIREMENTS.md .gsd/QUEUE.md + git add --force .sf/milestones/ .sf/PROJECT.md .sf/DECISIONS.md .sf/REQUIREMENTS.md .sf/QUEUE.md ``` 3. Ensure runtime files are NOT tracked: ``` - git rm --cached -r .gsd/runtime/ .gsd/activity/ .gsd/STATE.md .gsd/metrics.json .gsd/completed-units.json .gsd/auto.lock + git rm --cached -r .sf/runtime/ .sf/activity/ .sf/STATE.md .sf/metrics.json .sf/completed-units.json .sf/auto.lock ``` 4. Update README suggested `.gitignore` section 5. Remove `smartStage()` force-add of `SF_DURABLE_PATHS` — no longer needed since `.gitignore` doesn't block them -**Verification:** `git status` shows planning artifacts tracked, runtime files untracked. `git worktree add` on a new worktree has correct `.gsd/milestones/` state. +**Verification:** `git status` shows planning artifacts tracked, runtime files untracked. `git worktree add` on a new worktree has correct `.sf/milestones/` state. ### Phase 2: Remove Slice Branch Creation + Switching @@ -219,7 +219,7 @@ The function becomes: 7. Optional: `git push` 8. `removeWorktree()` + `git branch -D milestone/` -No conflict categorization. No runtime file stripping (runtime files are gitignored, not in the merge). No `.gsd/` special handling. +No conflict categorization. No runtime file stripping (runtime files are gitignored, not in the merge). No `.sf/` special handling. If squash-merge conflicts (parallel milestone edge case): stop auto-mode with clear error, user resolves manually or SF dispatches a one-time resolution session. @@ -238,8 +238,8 @@ If squash-merge conflicts (parallel milestone edge case): stop auto-mode with cl 2. Add new tests: - Branchless worktree lifecycle: create → commit → commit → squash-merge → cleanup - - `.gsd/` tracking: planning artifacts tracked, runtime files ignored - - Manual worktree: `git worktree add` has correct `.gsd/` state + - `.sf/` tracking: planning artifacts tracked, runtime files ignored + - Manual worktree: `git worktree add` has correct `.sf/` state - Crash recovery: dirty state on milestone branch, restart, auto-commit, continue 3. Remove merge-specific doctor checks or simplify: @@ -254,7 +254,7 @@ If squash-merge conflicts (parallel milestone edge case): stop auto-mode with cl **Goal:** Existing projects with slice branches continue to work. -1. State derivation (`deriveState()`) continues to read `gsd/M001/S01` branch naming for legacy detection +1. State derivation (`deriveState()`) continues to read `sf/M001/S01` branch naming for legacy detection 2. On first run after upgrade: - Detect existing slice branches - Notify user: "SF no longer creates slice branches. Existing branches are preserved but new work commits directly to the milestone branch." @@ -265,7 +265,7 @@ If squash-merge conflicts (parallel milestone edge case): stop auto-mode with cl - `git.isolation: "branch"` → warning, treated as worktree - Remove preference UI for isolation mode -**Verification:** Open a project with existing `gsd/M001/S01` branches. SF reads state correctly, new work commits on milestone branch without slice branches. +**Verification:** Open a project with existing `sf/M001/S01` branches. SF reads state correctly, new work commits on milestone branch without slice branches. ## Stress Test Results @@ -286,7 +286,7 @@ Validated by three independent models: - Confirmed `smartStage()` force-add already implements tracked-artifact intent - Confirmed `resolveMainWorktreeRoot` (PR #487) contradicts this architecture -- Confirmed `.gsd/milestones/` partially tracked on `main` despite `.gitignore` +- Confirmed `.sf/milestones/` partially tracked on `main` despite `.gitignore` - Verdict: **Model is sound. Removes only accidental complexity.** ### GPT-5.4 (Codex) — Dissenting Opinion @@ -298,7 +298,7 @@ Codex agreed on tracked artifacts and worktree-per-milestone, but pushed back on | Crash recovery for orphaned slice branches disappears | The failure mode (orphaned branch needing merge) is caused by slice branches. Removing branches removes the failure. Sequential commits on one branch need no orphan recovery. | | Concurrent edits to shared root docs (DECISIONS.md) from two terminals | Standard content conflict at squash-merge time. Not caused by or solved by slice branches. | | Continuous integration via slice→milestone merges | In sequential single-user work, there's nothing to integrate against within the worktree. Pre-flight rebase before squash-merge is more direct. | -| Need a replacement slice-boundary primitive | Accepted: conventional commit tags (`feat(M001/S01):`) + optional git tags (`gsd/M001/S01-complete`) serve as boundaries. | +| Need a replacement slice-boundary primitive | Accepted: conventional commit tags (`feat(M001/S01):`) + optional git tags (`sf/M001/S01-complete`) serve as boundaries. | Codex's analysis confirms the tracked-artifact approach but recommends treating branchless as a deliberate redesign with explicit replacement primitives, not a casual deletion. @@ -308,10 +308,10 @@ Scenario: M001 and M002 both modify `src/auth.ts`. M001 squash-merges first. Resolution: Before M002 squash-merges, rebase onto updated `main`: ``` -cd .gsd/worktrees/M002 +cd .sf/worktrees/M002 git fetch origin main git rebase main -# Resolve any conflicts (code-only, never .gsd/) +# Resolve any conflicts (code-only, never .sf/) # Then squash-merge ``` @@ -340,7 +340,7 @@ Resolution: Worktree is on `milestone/M001` branch, independent of `main`. Manua |--------|-------| | Merge/conflict/branch code | 770+ lines across 4 files | | Merge-related test files | 11 files | -| Branch types | 4 (main, milestone/*, gsd/*/*, worktree/*) | +| Branch types | 4 (main, milestone/*, sf/*/*, worktree/*) | | Merge strategies | 3 (--no-ff, --squash, conflict resolution) | | Dispatch unit types with merge logic | 2 (complete-slice, fix-merge) | | Isolation modes | 2 (branch, worktree) | @@ -370,14 +370,14 @@ Resolution: Worktree is on `milestone/M001` branch, independent of `main`. Manua ## Dependencies -- **M001 (Memory Database):** The SQLite database (`gsd.db`) must remain gitignored. The M001/S02 importer layer rebuilds it from tracked markdown. This PRD's `.gitignore` update explicitly ignores `gsd.db`. +- **M001 (Memory Database):** The SQLite database (`sf.db`) must remain gitignored. The M001/S02 importer layer rebuilds it from tracked markdown. This PRD's `.gitignore` update explicitly ignores `sf.db`. -- **PR #487:** Must be closed. The `resolveMainWorktreeRoot` approach (sharing `.gsd/` across worktrees) contradicts tracked-artifact architecture. +- **PR #487:** Must be closed. The `resolveMainWorktreeRoot` approach (sharing `.sf/` across worktrees) contradicts tracked-artifact architecture. ## Open Questions 1. **Squash vs `--no-ff` for milestone→main merge?** Squash gives clean history on `main` but loses bisect granularity. `--no-ff` preserves granular commits but clutters `main`. Current proposal: squash (matching existing behavior), with option to preserve milestone branch for debugging. -2. **Should `worktrees/` move outside `.gsd/`?** Having worktrees inside `.gsd/` creates a nesting-doll pattern (worktree contains `.gsd/` which is inside `.gsd/worktrees/`). Relocating to `.gsd-worktrees/` or `~/.gsd/worktrees//` is cleaner but changes the filesystem layout. Recommendation: defer, address separately if it causes issues. +2. **Should `worktrees/` move outside `.sf/`?** Having worktrees inside `.sf/` creates a nesting-doll pattern (worktree contains `.sf/` which is inside `.sf/worktrees/`). Relocating to `.sf-worktrees/` or `~/.sf/worktrees//` is cleaner but changes the filesystem layout. Recommendation: defer, address separately if it causes issues. 3. **Pre-flight rebase automation?** Before milestone→main squash-merge, should SF automatically `git rebase main`? Gemini recommends yes. Risk: rebase can fail with conflicts, adding a code path. Recommendation: implement as a doctor check ("milestone branch is behind main by N commits") with manual resolution, automate later if needed. diff --git a/docs/dev/PRD-pi-clean-seam-refactor.md b/docs/dev/PRD-pi-clean-seam-refactor.md index 957f7c2c9..1b02233e7 100644 --- a/docs/dev/PRD-pi-clean-seam-refactor.md +++ b/docs/dev/PRD-pi-clean-seam-refactor.md @@ -25,9 +25,9 @@ SF's code is clearly separated from pi's code at the module system level. The ve | Criterion | Measurement | |-----------|-------------| -| Zero SF business logic in vendored pi packages | `pi-coding-agent/src/` contains no files that import from `@gsd/` packages (except the extension system's bundled module map) | +| Zero SF business logic in vendored pi packages | `pi-coding-agent/src/` contains no files that import from `@sf/` packages (except the extension system's bundled module map) | | Module boundary is compiler-enforced | TypeScript `paths` config or package `exports` prevents pi packages from importing SF packages | -| Applying a pi-mono update is scoped | Updating pi packages produces type errors only in `@gsd/agent-core` and `@gsd/agent-modes` — no changes required in pi package source files | +| Applying a pi-mono update is scoped | Updating pi packages produces type errors only in `@sf/agent-core` and `@sf/agent-modes` — no changes required in pi package source files | | Install experience is unchanged | `npm install -g sf-run@latest` produces an identical binary from the user's perspective | | Existing extensions continue to work | All built-in SF extensions load and execute without modification | | Build time does not regress significantly | Full build completes within 120% of current baseline | @@ -43,14 +43,14 @@ SF's code is clearly separated from pi's code at the module system level. The ve ## Stakeholders - **Maintainers applying pi updates** — primary beneficiary; this work directly reduces their update burden -- **Extension authors** — must not be broken; the extension API surface stays in `@gsd/pi-coding-agent` +- **Extension authors** — must not be broken; the extension API surface stays in `@sf/pi-coding-agent` - **End users** — not impacted; the refactor is entirely internal ## Requirements -### R1 — New package: `@gsd/agent-core` +### R1 — New package: `@sf/agent-core` -A new workspace package at `packages/gsd-agent-core/` that owns all SF session orchestration logic. It depends on `@gsd/pi-coding-agent`, `@gsd/pi-agent-core`, and `@gsd/pi-ai`. Nothing in the vendored pi packages depends on it. +A new workspace package at `packages/sf-agent-core/` that owns all SF session orchestration logic. It depends on `@sf/pi-coding-agent`, `@sf/pi-agent-core`, and `@sf/pi-ai`. Nothing in the vendored pi packages depends on it. Must contain: - `agent-session.ts` and all `AgentSession` types @@ -66,9 +66,9 @@ Must contain: - `artifact-manager.ts`, `blob-store.ts` - `export-html/` -### R2 — New package: `@gsd/agent-modes` +### R2 — New package: `@sf/agent-modes` -A new workspace package at `packages/gsd-agent-modes/` that owns all run-mode and CLI code. It depends on `@gsd/agent-core`, `@gsd/pi-coding-agent`, and `@gsd/pi-tui`. It is the layer the top-level `sf-run` binary entry point assembles. +A new workspace package at `packages/sf-agent-modes/` that owns all run-mode and CLI code. It depends on `@sf/agent-core`, `@sf/pi-coding-agent`, and `@sf/pi-tui`. It is the layer the top-level `sf-run` binary entry point assembles. Must contain: - `modes/interactive/` (full TUI interactive mode and all components) @@ -80,32 +80,32 @@ Must contain: ### R3 — `pi-coding-agent` contains only upstream code and the extension system After the migration, the vendored `pi-coding-agent` source must not contain files that: -- Import from `@gsd/agent-core` or `@gsd/agent-modes` +- Import from `@sf/agent-core` or `@sf/agent-modes` - Contain SF business logic (compaction, session management, run modes, CLI) -The extension system (`src/core/extensions/`) remains in `pi-coding-agent` because it is legitimately pi-typed: extension authors write against pi's `AgentMessage`, `Model`, and `TUI` types. The virtual module map in `extensions/loader.ts` must be updated to include `@gsd/agent-core` and `@gsd/agent-modes` so extensions can import from them. +The extension system (`src/core/extensions/`) remains in `pi-coding-agent` because it is legitimately pi-typed: extension authors write against pi's `AgentMessage`, `Model`, and `TUI` types. The virtual module map in `extensions/loader.ts` must be updated to include `@sf/agent-core` and `@sf/agent-modes` so extensions can import from them. ### R4 — Public API surfaces are explicit Each new package must have an `index.ts` that declares its public API. Internal files must not be imported by path from outside the package. Specifically: -- `web/bridge-service.ts` currently imports `AgentSessionEvent` from an internal path in `pi-coding-agent` — this must be fixed to use the public export from `@gsd/agent-core` +- `web/bridge-service.ts` currently imports `AgentSessionEvent` from an internal path in `pi-coding-agent` — this must be fixed to use the public export from `@sf/agent-core` - Any other internal-path imports identified during migration must be fixed ### R5 — Build order is updated The workspace build script must be updated to build packages in dependency order: -1. `@gsd/pi-agent-core`, `@gsd/pi-ai`, `@gsd/pi-tui` (parallel, no dependencies between them) -2. `@gsd/pi-coding-agent` -3. `@gsd/agent-core` -4. `@gsd/agent-modes` +1. `@sf/pi-agent-core`, `@sf/pi-ai`, `@sf/pi-tui` (parallel, no dependencies between them) +2. `@sf/pi-coding-agent` +3. `@sf/agent-core` +4. `@sf/agent-modes` 5. `sf-run` (top-level binary) ### R6 — No change to the extension loader's public interface -Extensions are loaded by `pi-coding-agent`'s jiti-based loader. The virtual module map (`STATIC_BUNDLED_MODULES`) must be updated to resolve `@gsd/agent-core` and `@gsd/agent-modes` alongside the existing pi package mappings. This requires both a map entry and a top-level bundle import in `loader.ts` (see ADR-009 for the exact diff). Extension authors must not need to change their import paths. +Extensions are loaded by `pi-coding-agent`'s jiti-based loader. The virtual module map (`STATIC_BUNDLED_MODULES`) must be updated to resolve `@sf/agent-core` and `@sf/agent-modes` alongside the existing pi package mappings. This requires both a map entry and a top-level bundle import in `loader.ts` (see ADR-009 for the exact diff). Extension authors must not need to change their import paths. ## Open Questions 1. Does `clearQueue()` on `AgentSession` need to be added to a public type export, or is it already accessible to the auto-mode extension that uses it? -2. Does `buildSessionContext()` on `SessionManager` need a public re-export from `@gsd/agent-core`? -3. Should `@gsd/agent-modes` re-export `createAgentSession()` as a convenience, or should consumers always import it from `@gsd/agent-core` directly? +2. Does `buildSessionContext()` on `SessionManager` need a public re-export from `@sf/agent-core`? +3. Should `@sf/agent-modes` re-export `createAgentSession()` as a convenience, or should consumers always import it from `@sf/agent-core` directly? diff --git a/docs/dev/agent-knowledge-index.md b/docs/dev/agent-knowledge-index.md index 6d9cb6c77..ca7ce2999 100644 --- a/docs/dev/agent-knowledge-index.md +++ b/docs/dev/agent-knowledge-index.md @@ -21,30 +21,30 @@ Use when: Read first: -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/01-what-pi-is.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/04-the-architecture-how-everything-fits-together.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/05-the-agent-loop-how-pi-thinks.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/01-what-pi-is.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/04-the-architecture-how-everything-fits-together.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/05-the-agent-loop-how-pi-thinks.md` Read together when relevant: -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/06-tools-how-pi-acts-on-the-world.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/07-sessions-memory-that-branches.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/08-compaction-how-pi-manages-context-limits.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/09-the-customization-stack.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/10-providers-models-multi-model-by-default.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/13-context-files-project-instructions.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/06-tools-how-pi-acts-on-the-world.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/07-sessions-memory-that-branches.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/08-compaction-how-pi-manages-context-limits.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/09-the-customization-stack.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/10-providers-models-multi-model-by-default.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/13-context-files-project-instructions.md` Follow-up if needed: -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/03-the-four-modes-of-operation.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/11-the-interactive-tui.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/12-the-message-queue-talking-while-pi-thinks.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/14-the-sdk-rpc-embedding-pi.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/15-pi-packages-the-ecosystem.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/16-why-pi-matters-what-makes-it-different.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/17-file-reference-all-documentation.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/18-quick-reference-commands-shortcuts.md` -- `/Users/lexchristopherson/.gsd/docs/what-is-pi/19-building-branded-apps-on-top-of-pi.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/03-the-four-modes-of-operation.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/11-the-interactive-tui.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/12-the-message-queue-talking-while-pi-thinks.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/14-the-sdk-rpc-embedding-pi.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/15-pi-packages-the-ecosystem.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/16-why-pi-matters-what-makes-it-different.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/17-file-reference-all-documentation.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/18-quick-reference-commands-shortcuts.md` +- `/Users/lexchristopherson/.sf/docs/what-is-pi/19-building-branded-apps-on-top-of-pi.md` ## Context engineering, hooks, and context flow @@ -60,16 +60,16 @@ Use when: Read first: -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/01-the-context-pipeline.md` -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/02-hook-reference.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/01-the-context-pipeline.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/02-hook-reference.md` Read together when relevant: -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/03-context-injection-patterns.md` -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/04-message-types-and-llm-visibility.md` -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/05-inter-extension-communication.md` -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/06-advanced-patterns-from-source.md` -- `/Users/lexchristopherson/.gsd/docs/context-and-hooks/07-the-system-prompt-anatomy.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/03-context-injection-patterns.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/04-message-types-and-llm-visibility.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/05-inter-extension-communication.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/06-advanced-patterns-from-source.md` +- `/Users/lexchristopherson/.sf/docs/context-and-hooks/07-the-system-prompt-anatomy.md` ## Extension development @@ -80,37 +80,37 @@ Use when: Read first: -- `/Users/lexchristopherson/.gsd/docs/extending-pi/01-what-are-extensions.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/02-architecture-mental-model.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/03-getting-started.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/01-what-are-extensions.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/02-architecture-mental-model.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/03-getting-started.md` Read together when relevant: -- `/Users/lexchristopherson/.gsd/docs/extending-pi/06-the-extension-lifecycle.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/07-events-the-nervous-system.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/08-extensioncontext-what-you-can-access.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/09-extensionapi-what-you-can-do.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/10-custom-tools-giving-the-llm-new-abilities.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/11-custom-commands-user-facing-actions.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/14-custom-rendering-controlling-what-the-user-sees.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/25-slash-command-subcommand-patterns.md` # for subcommand-style slash command UX via getArgumentCompletions() -- `/Users/lexchristopherson/.gsd/docs/extending-pi/15-system-prompt-modification.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/22-key-rules-gotchas.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/06-the-extension-lifecycle.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/07-events-the-nervous-system.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/08-extensioncontext-what-you-can-access.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/09-extensionapi-what-you-can-do.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/10-custom-tools-giving-the-llm-new-abilities.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/11-custom-commands-user-facing-actions.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/14-custom-rendering-controlling-what-the-user-sees.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/25-slash-command-subcommand-patterns.md` # for subcommand-style slash command UX via getArgumentCompletions() +- `/Users/lexchristopherson/.sf/docs/extending-pi/15-system-prompt-modification.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/22-key-rules-gotchas.md` Follow-up if needed: -- `/Users/lexchristopherson/.gsd/docs/extending-pi/04-extension-locations-discovery.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/05-extension-structure-styles.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/12-custom-ui-visual-components.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/13-state-management-persistence.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/16-compaction-session-control.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/17-model-provider-management.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/18-remote-execution-tool-overrides.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/19-packaging-distribution.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/20-mode-behavior.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/21-error-handling.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/23-file-reference-documentation.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/24-file-reference-example-extensions.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/04-extension-locations-discovery.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/05-extension-structure-styles.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/12-custom-ui-visual-components.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/13-state-management-persistence.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/16-compaction-session-control.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/17-model-provider-management.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/18-remote-execution-tool-overrides.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/19-packaging-distribution.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/20-mode-behavior.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/21-error-handling.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/23-file-reference-documentation.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/24-file-reference-example-extensions.md` ## Pi UI and TUI @@ -121,35 +121,35 @@ Use when: Read first: -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/01-the-ui-architecture.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/03-entry-points-how-ui-gets-on-screen.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/22-quick-reference-all-ui-apis.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/01-the-ui-architecture.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/03-entry-points-how-ui-gets-on-screen.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/22-quick-reference-all-ui-apis.md` Read together when relevant: -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/04-built-in-dialog-methods.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/05-persistent-ui-elements.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/06-ctx-ui-custom-full-custom-components.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/07-built-in-components-the-building-blocks.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/12-overlays-floating-modals-and-panels.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/13-custom-editors-replacing-the-input.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/14-tool-rendering-custom-tool-display.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/15-message-rendering-custom-message-display.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/21-common-mistakes-and-how-to-avoid-them.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/04-built-in-dialog-methods.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/05-persistent-ui-elements.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/06-ctx-ui-custom-full-custom-components.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/07-built-in-components-the-building-blocks.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/12-overlays-floating-modals-and-panels.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/13-custom-editors-replacing-the-input.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/14-tool-rendering-custom-tool-display.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/15-message-rendering-custom-message-display.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/21-common-mistakes-and-how-to-avoid-them.md` Follow-up if needed: -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/02-the-component-interface-foundation-of-everything.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/08-high-level-components-from-pi-coding-agent.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/09-keyboard-input-how-to-handle-keys.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/10-line-width-the-cardinal-rule.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/11-theming-colors-and-styles.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/16-performance-caching-and-invalidation.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/17-theme-changes-and-invalidation.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/18-ime-support-the-focusable-interface.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/19-building-a-complete-component-step-by-step.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/20-real-world-patterns-from-examples.md` -- `/Users/lexchristopherson/.gsd/docs/pi-ui-tui/23-file-reference-example-extensions-with-ui.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/02-the-component-interface-foundation-of-everything.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/08-high-level-components-from-pi-coding-agent.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/09-keyboard-input-how-to-handle-keys.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/10-line-width-the-cardinal-rule.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/11-theming-colors-and-styles.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/16-performance-caching-and-invalidation.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/17-theme-changes-and-invalidation.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/18-ime-support-the-focusable-interface.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/19-building-a-complete-component-step-by-step.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/20-real-world-patterns-from-examples.md` +- `/Users/lexchristopherson/.sf/docs/pi-ui-tui/23-file-reference-example-extensions-with-ui.md` ## Building coding agents @@ -161,38 +161,38 @@ Use when: Read first: -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/01-work-decomposition.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/06-maximizing-agent-autonomy-superpowers.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/11-god-tier-context-engineering.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/12-handling-ambiguity-contradiction.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/26-cross-cutting-themes-where-all-4-models-converge.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/01-work-decomposition.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/06-maximizing-agent-autonomy-superpowers.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/11-god-tier-context-engineering.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/12-handling-ambiguity-contradiction.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/26-cross-cutting-themes-where-all-4-models-converge.md` Read together when relevant: -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/03-state-machine-context-management.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/04-optimal-storage-for-project-context.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/05-parallelization-strategy.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/07-system-prompt-llm-vs-deterministic-split.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/08-speed-optimization.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/10-top-10-pitfalls-to-avoid.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/17-irreversible-operations-safety-architecture.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/20-error-taxonomy-routing.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/24-security-trust-boundaries.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/03-state-machine-context-management.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/04-optimal-storage-for-project-context.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/05-parallelization-strategy.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/07-system-prompt-llm-vs-deterministic-split.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/08-speed-optimization.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/10-top-10-pitfalls-to-avoid.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/17-irreversible-operations-safety-architecture.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/20-error-taxonomy-routing.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/24-security-trust-boundaries.md` Follow-up if needed: -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/02-what-to-keep-discard-from-human-engineering.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/09-top-10-tips-for-a-world-class-agent.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/13-long-running-memory-fidelity.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/14-multi-agent-semantic-conflict-resolution.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/15-legacy-code-brownfield-onboarding.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/16-encoding-taste-aesthetics.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/18-the-handoff-problem-agent-human-maintainability.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/19-when-to-scrap-and-start-over.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/21-cost-quality-tradeoff-model-routing.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/22-cross-project-learning-reusable-intelligence.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/23-evolution-across-project-scale.md` -- `/Users/lexchristopherson/.gsd/docs/building-coding-agents/25-designing-for-non-technical-users-vibe-coders.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/02-what-to-keep-discard-from-human-engineering.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/09-top-10-tips-for-a-world-class-agent.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/13-long-running-memory-fidelity.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/14-multi-agent-semantic-conflict-resolution.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/15-legacy-code-brownfield-onboarding.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/16-encoding-taste-aesthetics.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/18-the-handoff-problem-agent-human-maintainability.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/19-when-to-scrap-and-start-over.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/21-cost-quality-tradeoff-model-routing.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/22-cross-project-learning-reusable-intelligence.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/23-evolution-across-project-scale.md` +- `/Users/lexchristopherson/.sf/docs/building-coding-agents/25-designing-for-non-technical-users-vibe-coders.md` ## Pi product docs diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 65dcba6a8..f09d6c4ae 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -5,31 +5,31 @@ SF is a TypeScript application built on the [Pi SDK](https://github.com/badlogic ## System Structure ``` -gsd (CLI binary) +sf (CLI binary) └─ loader.ts Sets PI_PACKAGE_DIR, SF env vars, dynamic-imports cli.ts └─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode ├─ onboarding.ts First-run setup wizard (LLM provider + tool keys) ├─ wizard.ts Env hydration from stored auth.json credentials - ├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json - ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/ + ├─ app-paths.ts ~/.sf/agent/, ~/.sf/sessions/, auth.json + ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.sf/agent/ └─ src/resources/ - ├─ extensions/gsd/ Core SF extension + ├─ extensions/sf/ Core SF extension ├─ extensions/... 23 supporting extensions ├─ agents/ scout, researcher, worker ├─ AGENTS.md Agent routing instructions └─ SF-WORKFLOW.md Manual bootstrap protocol -gsd headless Headless mode — CI/cron orchestration via RPC child process -gsd --mode mcp MCP server mode — exposes tools over stdin/stdout +sf headless Headless mode — CI/cron orchestration via RPC child process +sf --mode mcp MCP server mode — exposes tools over stdin/stdout -vscode-extension/ VS Code extension — chat participant (@gsd), sidebar dashboard, RPC integration +vscode-extension/ VS Code extension — chat participant (@sf), sidebar dashboard, RPC integration ``` ## Key Design Decisions ### State Lives on Disk -`.gsd/` is the sole source of truth. Auto mode reads it, writes it, and advances based on what it finds. No in-memory state survives across sessions. This enables crash recovery, multi-terminal steering, and session resumption. +`.sf/` is the sole source of truth. Auto mode reads it, writes it, and advances based on what it finds. No in-memory state survives across sessions. This enables crash recovery, multi-terminal steering, and session resumption. ### Two-File Loader Pattern @@ -41,7 +41,7 @@ vscode-extension/ VS Code extension — chat participant (@gsd), sidebar ### Always-Overwrite Sync -Bundled extensions and agents are synced to `~/.gsd/agent/` on every launch, not just first run. This means `npm update -g` takes effect immediately. +Bundled extensions and agents are synced to `~/.sf/agent/` on every launch, not just first run. This means `npm update -g` takes effect immediately. ### Lazy Provider Loading diff --git a/docs/dev/ci-cd-pipeline.md b/docs/dev/ci-cd-pipeline.md index 8528a4a1f..4b31ba707 100644 --- a/docs/dev/ci-cd-pipeline.md +++ b/docs/dev/ci-cd-pipeline.md @@ -80,7 +80,7 @@ docker run --rm -v $(pwd):/workspace ghcr.io/singularity-forge/sf-run:latest --v CI automatically detects when a PR contains only documentation changes (`.md` files and `docs/` content). When docs-only: - **Skipped:** `build`, `windows-portability` (no code to compile or test) -- **Still runs:** `lint` (secret scanning, `.gsd/` check), `docs-check` (prompt injection scan) +- **Still runs:** `lint` (secret scanning, `.sf/` check), `docs-check` (prompt injection scan) This saves CI minutes on documentation PRs while still enforcing security checks. @@ -147,7 +147,7 @@ For `@dev` or `@next` rollbacks, the next successful merge will overwrite the ta | Secret: `ANTHROPIC_API_KEY` | Prod environment only | | Secret: `OPENAI_API_KEY` | Prod environment only | | Variable: `RUN_LIVE_TESTS` | `false` (set to `true` to enable live LLM tests) | -| GHCR | Enabled for the `gsd-build` org | +| GHCR | Enabled for the `sf-build` org | ### Docker Images diff --git a/docs/dev/context-and-hooks/07-the-system-prompt-anatomy.md b/docs/dev/context-and-hooks/07-the-system-prompt-anatomy.md index 7bb2c57cc..34e9cb2ae 100644 --- a/docs/dev/context-and-hooks/07-the-system-prompt-anatomy.md +++ b/docs/dev/context-and-hooks/07-the-system-prompt-anatomy.md @@ -20,7 +20,7 @@ When `buildSystemPrompt()` runs, it assembles sections in this exact order: │ 2. Append system prompt (APPEND_SYSTEM.md) │ │ │ │ 3. Project context files │ -│ ├── ~/.gsd/agent/AGENTS.md (global) │ +│ ├── ~/.sf/agent/AGENTS.md (global) │ │ ├── Ancestor AGENTS.md / CLAUDE.md files │ │ └── cwd AGENTS.md / CLAUDE.md │ │ │ @@ -72,7 +72,7 @@ Pi documentation (read only when the user asks about pi itself...): ### SYSTEM.md Override (full replacement) -If `.gsd/SYSTEM.md` (project) or `~/.gsd/agent/SYSTEM.md` (global) exists, its contents **completely replace** the default base prompt above. The tools list, guidelines, pi docs pointers — all gone. You own the entire base. +If `.sf/SYSTEM.md` (project) or `~/.sf/agent/SYSTEM.md` (global) exists, its contents **completely replace** the default base prompt above. The tools list, guidelines, pi docs pointers — all gone. You own the entire base. Project takes precedence over global. Only one SYSTEM.md is used (first found wins). @@ -124,7 +124,7 @@ Guidelines are assembled dynamically based on which tools are active: ## Section 2: Append System Prompt -If `.gsd/APPEND_SYSTEM.md` (project) or `~/.gsd/agent/APPEND_SYSTEM.md` (global) exists, its contents are appended after the base prompt. +If `.sf/APPEND_SYSTEM.md` (project) or `~/.sf/agent/APPEND_SYSTEM.md` (global) exists, its contents are appended after the base prompt. This is the safe way to add project-wide instructions without replacing the default prompt. It works with both the default base and a custom SYSTEM.md. @@ -135,7 +135,7 @@ This is the safe way to add project-wide instructions without replacing the defa Pi walks the filesystem collecting context files: ``` -1. ~/.gsd/agent/AGENTS.md (global) +1. ~/.sf/agent/AGENTS.md (global) 2. Walk from cwd upward to root: - Each directory: check for AGENTS.md, then CLAUDE.md (first found wins per directory) - Files are collected root-down (ancestors first, cwd last) @@ -148,7 +148,7 @@ All found files are concatenated under a "# Project Context" header: Project-specific instructions and guidelines: -## /Users/you/.gsd/agent/AGENTS.md +## /Users/you/.sf/agent/AGENTS.md [global AGENTS.md content] diff --git a/docs/dev/extending-pi/03-getting-started.md b/docs/dev/extending-pi/03-getting-started.md index 3d4eb2909..df80c47ee 100644 --- a/docs/dev/extending-pi/03-getting-started.md +++ b/docs/dev/extending-pi/03-getting-started.md @@ -3,7 +3,7 @@ ### Minimal Extension -Create `~/.gsd/agent/extensions/my-extension.ts`: +Create `~/.sf/agent/extensions/my-extension.ts`: ```typescript import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -27,7 +27,7 @@ pi ### Hot Reload -Extensions in auto-discovered locations (`~/.gsd/agent/extensions/` or `.gsd/extensions/`) can be hot-reloaded: +Extensions in auto-discovered locations (`~/.sf/agent/extensions/` or `.sf/extensions/`) can be hot-reloaded: ``` /reload diff --git a/docs/dev/extending-pi/04-extension-locations-discovery.md b/docs/dev/extending-pi/04-extension-locations-discovery.md index d7090f57c..c663aa8a2 100644 --- a/docs/dev/extending-pi/04-extension-locations-discovery.md +++ b/docs/dev/extending-pi/04-extension-locations-discovery.md @@ -5,10 +5,10 @@ | Location | Scope | |----------|-------| -| `~/.gsd/agent/extensions/*.ts` | Global (all projects) | -| `~/.gsd/agent/extensions/*/index.ts` | Global (subdirectory) | -| `.gsd/extensions/*.ts` | Project-local | -| `.gsd/extensions/*/index.ts` | Project-local (subdirectory) | +| `~/.sf/agent/extensions/*.ts` | Global (all projects) | +| `~/.sf/agent/extensions/*/index.ts` | Global (subdirectory) | +| `.sf/extensions/*.ts` | Project-local | +| `.sf/extensions/*/index.ts` | Project-local (subdirectory) | ### Additional Paths (via settings.json) diff --git a/docs/dev/extending-pi/05-extension-structure-styles.md b/docs/dev/extending-pi/05-extension-structure-styles.md index 86e6c4ab7..26fae41e5 100644 --- a/docs/dev/extending-pi/05-extension-structure-styles.md +++ b/docs/dev/extending-pi/05-extension-structure-styles.md @@ -4,14 +4,14 @@ ### Single File (simplest) ``` -~/.gsd/agent/extensions/ +~/.sf/agent/extensions/ └── my-extension.ts ``` ### Directory with index.ts (multi-file) ``` -~/.gsd/agent/extensions/ +~/.sf/agent/extensions/ └── my-extension/ ├── index.ts # Entry point (must export default function) ├── tools.ts @@ -21,7 +21,7 @@ ### Package with Dependencies (npm packages needed) ``` -~/.gsd/agent/extensions/ +~/.sf/agent/extensions/ └── my-extension/ ├── package.json ├── package-lock.json diff --git a/docs/dev/extending-pi/25-slash-command-subcommand-patterns.md b/docs/dev/extending-pi/25-slash-command-subcommand-patterns.md index ad883a238..4858725d5 100644 --- a/docs/dev/extending-pi/25-slash-command-subcommand-patterns.md +++ b/docs/dev/extending-pi/25-slash-command-subcommand-patterns.md @@ -175,7 +175,7 @@ This is how `/wt switch`, `/wt merge`, and `/wt rm` can suggest current worktree The worktree extension uses this exact structure in: -- `/Users/lexchristopherson/.gsd/agent/extensions/worktree/index.ts` +- `/Users/lexchristopherson/.sf/agent/extensions/worktree/index.ts` It defines: @@ -307,9 +307,9 @@ description: "Manage foo items: /foo new|list|delete [name]" Read these alongside this pattern: -- `/Users/lexchristopherson/.gsd/docs/extending-pi/11-custom-commands-user-facing-actions.md` -- `/Users/lexchristopherson/.gsd/docs/extending-pi/09-extensionapi-what-you-can-do.md` -- `/Users/lexchristopherson/.gsd/agent/extensions/worktree/index.ts` +- `/Users/lexchristopherson/.sf/docs/extending-pi/11-custom-commands-user-facing-actions.md` +- `/Users/lexchristopherson/.sf/docs/extending-pi/09-extensionapi-what-you-can-do.md` +- `/Users/lexchristopherson/.sf/agent/extensions/worktree/index.ts` ## Summary diff --git a/docs/dev/pi-context-optimization-opportunities.md b/docs/dev/pi-context-optimization-opportunities.md index 738c7c581..710a8733f 100644 --- a/docs/dev/pi-context-optimization-opportunities.md +++ b/docs/dev/pi-context-optimization-opportunities.md @@ -95,7 +95,7 @@ COMPACTION_RESERVE_TOKENS = contextWindow * (1 - COMPACTION_THRESHOLD_PERCENT) ## 5. Context File Deduplication and Trim **Current state** (`packages/pi-coding-agent/src/core/resource-loader.ts`, lines 84–109): -- Searches from `~/.gsd/agent/` → ancestor dirs → cwd +- Searches from `~/.sf/agent/` → ancestor dirs → cwd - Deduplicates by *file path* but not by *content* - Entire file content concatenated verbatim into system prompt — no trimming, no summarization @@ -178,7 +178,7 @@ interface CostCheckpointEvent { } ``` -SF extension could consume these events to surface per-milestone cost in `/gsd stats` and flag milestones that are disproportionately expensive — enabling budget-aware planning. +SF extension could consume these events to surface per-milestone cost in `/sf stats` and flag milestones that are disproportionately expensive — enabling budget-aware planning. --- diff --git a/docs/dev/proposals/698-browser-tools-feature-additions.md b/docs/dev/proposals/698-browser-tools-feature-additions.md index 57b30fba9..8f6becb55 100644 --- a/docs/dev/proposals/698-browser-tools-feature-additions.md +++ b/docs/dev/proposals/698-browser-tools-feature-additions.md @@ -55,7 +55,7 @@ Save cookies, localStorage, sessionStorage, and auth tokens to disk. Restore the |---|---| | **New tools in** | `tools/session.ts` (extend existing file) | | **Playwright API** | `context.storageState()` for cookies + localStorage; `page.evaluate()` for sessionStorage (not included in Playwright's storageState) | -| **Storage location** | Session artifacts directory: `.gsd/browser-state/.json` | +| **Storage location** | Session artifacts directory: `.sf/browser-state/.json` | | **Tool signatures** | `browser_save_state({ name?: string })` → `{ path, cookieCount, localStorageOrigins }` / `browser_restore_state({ name?: string })` → `{ restored, cookieCount }` | | **Restore mechanism** | `browser.newContext({ storageState: path })` for new sessions; `context.addCookies()` + `page.evaluate()` for mid-session restore | | **Security** | State files may contain auth tokens — add to `.gitignore` pattern, warn in tool output | @@ -68,7 +68,7 @@ Save cookies, localStorage, sessionStorage, and auth tokens to disk. Restore the - [ ] Saves sessionStorage via `page.evaluate()` (per-origin) - [ ] Restores state on new browser context launch - [ ] Restores state mid-session (cookies + evaluate injection) -- [ ] State files written to `.gsd/browser-state/` and gitignored +- [ ] State files written to `.sf/browser-state/` and gitignored - [ ] Tool output shows count of restored items, never displays secret values --- @@ -174,7 +174,7 @@ Compare two screenshots pixel-by-pixel, return a diff image and similarity score | **New file** | `tools/visual-diff.ts` | | **Comparison library** | `pixelmatch` (lightweight, ~200 lines, MIT) or Playwright's built-in `expect(page).toHaveScreenshot()` comparison | | **Tool signature** | `browser_visual_diff({ baseline?: string, current?: string, threshold?: number })` → `{ match: boolean, similarity: number, diffPixels: number, diffImagePath?: string }` | -| **Baseline management** | Save baselines to `.gsd/browser-baselines/`; auto-name by URL + viewport | +| **Baseline management** | Save baselines to `.sf/browser-baselines/`; auto-name by URL + viewport | | **Dependencies** | `pixelmatch` + `pngjs` (new deps, ~50KB total) or use Playwright's built-in comparator | | **Estimated effort** | **10–14 hours** | | **Risk** | Medium — anti-aliasing and dynamic content (timestamps, ads) cause false positives; threshold tuning needed | diff --git a/docs/dev/superpowers/plans/2026-03-17-cicd-pipeline.md b/docs/dev/superpowers/plans/2026-03-17-cicd-pipeline.md index 679694f14..b59dde61a 100644 --- a/docs/dev/superpowers/plans/2026-03-17-cicd-pipeline.md +++ b/docs/dev/superpowers/plans/2026-03-17-cicd-pipeline.md @@ -23,9 +23,9 @@ | `.github/workflows/cleanup-dev-versions.yml` | Weekly scheduled cleanup of old `-dev.` npm versions | | `scripts/version-stamp.mjs` | Reads `package.json` version, appends `-dev.`, writes back | | `tests/smoke/run.ts` | Smoke test runner — discovers and executes all smoke tests | -| `tests/smoke/test-version.ts` | Verify `gsd --version` outputs valid semver | -| `tests/smoke/test-help.ts` | Verify `gsd --help` exits 0 and contains expected output | -| `tests/smoke/test-init.ts` | Verify `gsd init` creates expected files in a temp dir | +| `tests/smoke/test-version.ts` | Verify `sf --version` outputs valid semver | +| `tests/smoke/test-help.ts` | Verify `sf --help` exits 0 and contains expected output | +| `tests/smoke/test-init.ts` | Verify `sf init` creates expected files in a temp dir | | `tests/fixtures/provider.ts` | `FixtureProvider` — wraps `ApiProvider`, records/replays turns | | `tests/fixtures/run.ts` | Fixture test runner — loads recordings, replays via `FixtureProvider` | | `tests/fixtures/record.ts` | Recording helper — runs a session with `SF_FIXTURE_MODE=record` | @@ -141,13 +141,13 @@ RUN npm install -g sf-run@${SF_VERSION} # Default working directory for user projects WORKDIR /workspace -ENTRYPOINT ["gsd"] +ENTRYPOINT ["sf"] CMD ["--help"] ``` - [ ] **Step 2: Verify builder stage builds** -Run: `docker build --target builder -t gsd-ci-builder-test .` +Run: `docker build --target builder -t sf-ci-builder-test .` Expected: Completes successfully (may take 5-10 min first time) - [ ] **Step 3: Verify runtime stage builds** @@ -225,7 +225,7 @@ if (failed > 0) process.exit(1); ```typescript // tests/smoke/test-version.ts -// Verifies that `gsd --version` outputs valid semver-like string. +// Verifies that `sf --version` outputs valid semver-like string. // When SF_SMOKE_BINARY is set (CI), uses that binary directly. // Otherwise falls back to npx sf-run. @@ -248,7 +248,7 @@ console.log(`version: ${output}`); ```typescript // tests/smoke/test-help.ts -// Verifies that `gsd --help` exits 0 and contains expected keywords. +// Verifies that `sf --help` exits 0 and contains expected keywords. import { execFileSync } from "child_process"; @@ -257,7 +257,7 @@ const output = bin ? execFileSync(bin, ["--help"], { encoding: "utf8", timeout: 30_000 }) : execFileSync("npx", ["sf-run", "--help"], { encoding: "utf8", timeout: 30_000 }); -const requiredKeywords = ["gsd", "usage"]; +const requiredKeywords = ["sf", "usage"]; for (const keyword of requiredKeywords) { if (!output.toLowerCase().includes(keyword)) { console.error(`Missing keyword "${keyword}" in help output`); @@ -272,14 +272,14 @@ console.log("help output OK"); ```typescript // tests/smoke/test-init.ts -// Verifies that `gsd init` creates expected files in a temp directory. +// Verifies that `sf init` creates expected files in a temp directory. import { execFileSync } from "child_process"; import { mkdtempSync, existsSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -const tmp = mkdtempSync(join(tmpdir(), "gsd-smoke-init-")); +const tmp = mkdtempSync(join(tmpdir(), "sf-smoke-init-")); try { const bin = process.env.SF_SMOKE_BINARY; @@ -291,9 +291,9 @@ try { env: { ...process.env, SF_NON_INTERACTIVE: "1" }, }); - // Check that .gsd directory was created - if (!existsSync(join(tmp, ".gsd"))) { - console.error("Expected .gsd/ directory not found after init"); + // Check that .sf directory was created + if (!existsSync(join(tmp, ".sf"))) { + console.error("Expected .sf/ directory not found after init"); process.exit(1); } @@ -484,7 +484,7 @@ export class FixtureReplayer { } ``` -Note: This provider implements the core recording/replay data structures and utilities. Wiring it into the `pi-ai` registry as a drop-in `ApiProvider` (via `registerApiProvider()` from `packages/pi-ai/src/api-registry.ts`) requires importing `@gsd/pi-ai` internals, which couples tests to the build output. This integration is deferred to a follow-up task after the pipeline is operational. The current implementation validates fixture format, turn sequencing, and replay correctness independently. +Note: This provider implements the core recording/replay data structures and utilities. Wiring it into the `pi-ai` registry as a drop-in `ApiProvider` (via `registerApiProvider()` from `packages/pi-ai/src/api-registry.ts`) requires importing `@sf/pi-ai` internals, which couples tests to the build output. This integration is deferred to a follow-up task after the pipeline is operational. The current implementation validates fixture format, turn sequencing, and replay correctness independently. - [ ] **Step 2: Verify the file has no syntax errors** @@ -1038,7 +1038,7 @@ jobs: mkdir /tmp/smoke-test && cd /tmp/smoke-test npm init -y npm install sf-run@dev - npx gsd --version + npx sf --version # ─── TEST STAGE ──────────────────────────────────────────── test-verify: @@ -1067,7 +1067,7 @@ jobs: - name: Run CLI smoke tests run: npm run test:smoke env: - SF_SMOKE_BINARY: gsd # Use globally installed binary, not npx + SF_SMOKE_BINARY: sf # Use globally installed binary, not npx - name: Run fixture replay tests run: npm run test:fixtures @@ -1141,7 +1141,7 @@ jobs: mkdir /tmp/prod-smoke && cd /tmp/prod-smoke npm init -y npm install sf-run@latest - npx gsd --version + npx sf --version # ─── CI BUILDER IMAGE (conditional) ──────────────────────── update-builder: @@ -1395,7 +1395,7 @@ These steps require repo admin access and cannot be automated: - `RUN_LIVE_TESTS` → `false` by default on `prod` (set to `true` to enable) 4. **Enable GHCR:** - - Ensure GitHub Container Registry is enabled for the `gsd-build` org + - Ensure GitHub Container Registry is enabled for the `sf-build` org 5. **Test the pipeline end-to-end:** - Merge a test PR to `main` diff --git a/docs/dev/superpowers/specs/2026-03-17-cicd-pipeline-design.md b/docs/dev/superpowers/specs/2026-03-17-cicd-pipeline-design.md index a1bb4630f..04331a610 100644 --- a/docs/dev/superpowers/specs/2026-03-17-cicd-pipeline-design.md +++ b/docs/dev/superpowers/specs/2026-03-17-cicd-pipeline-design.md @@ -80,7 +80,7 @@ The `-dev.` prerelease identifier is distinct from the existing `-next.` convent ### Native Binary Strategy for Dev Publishes -Dev versions (`@dev` tag) use the native binaries from the most recent stable `build-native.yml` release. The `optionalDependencies` in `package.json` use `>=` ranges, so a `-dev.` version of `sf-run` resolves the latest stable `@gsd-build/engine-*` packages from the registry. +Dev versions (`@dev` tag) use the native binaries from the most recent stable `build-native.yml` release. The `optionalDependencies` in `package.json` use `>=` ranges, so a `-dev.` version of `sf-run` resolves the latest stable `@sf-build/engine-*` packages from the registry. If a PR modifies Rust native crate code (`native/` directory), the dev publish will bundle stale native binaries. This is acceptable because: - Native crate changes are infrequent and always accompanied by a `v*` tag release @@ -183,11 +183,11 @@ FixtureProvider (intercept layer) ### Integration Design -The `FixtureProvider` implements the `Provider` interface from `@gsd/pi-ai` (the same interface all 20+ built-in providers implement). It registers itself via environment variable detection at provider initialization: +The `FixtureProvider` implements the `Provider` interface from `@sf/pi-ai` (the same interface all 20+ built-in providers implement). It registers itself via environment variable detection at provider initialization: ```typescript // Pseudocode — actual implementation will follow pi-ai patterns -import type { Provider, StreamingResponse } from "@gsd/pi-ai"; +import type { Provider, StreamingResponse } from "@sf/pi-ai"; class FixtureProvider implements Provider { // In record mode: wraps the real provider, saves responses diff --git a/docs/dev/what-is-pi/07-sessions-memory-that-branches.md b/docs/dev/what-is-pi/07-sessions-memory-that-branches.md index cc4b4cfeb..3c63db6ea 100644 --- a/docs/dev/what-is-pi/07-sessions-memory-that-branches.md +++ b/docs/dev/what-is-pi/07-sessions-memory-that-branches.md @@ -7,7 +7,7 @@ Sessions are pi's memory system. They're more sophisticated than simple conversa Sessions are **JSONL files** (one JSON object per line). Each line is an "entry" with a `type`, `id`, and `parentId`: ``` -~/.gsd/agent/sessions/--path--to--project--/_.jsonl +~/.sf/agent/sessions/--path--to--project--/_.jsonl ``` ### The Entry Tree diff --git a/docs/dev/what-is-pi/09-the-customization-stack.md b/docs/dev/what-is-pi/09-the-customization-stack.md index 10d032b39..b5bc7ac7f 100644 --- a/docs/dev/what-is-pi/09-the-customization-stack.md +++ b/docs/dev/what-is-pi/09-the-customization-stack.md @@ -26,8 +26,8 @@ Pi has four layers of customization, each serving a different purpose: TypeScript modules with full runtime access. They can hook into every event, register tools the LLM can call, add commands, render custom UI, override built-in behavior, and register model providers. Extensions are the most powerful customization mechanism. **Placement:** -- `~/.gsd/agent/extensions/` (global) -- `.gsd/extensions/` (project-local) +- `~/.sf/agent/extensions/` (global) +- `.sf/extensions/` (project-local) See the companion doc **Pi-Extensions-Complete-Guide.md** for the full 50KB reference. @@ -66,7 +66,7 @@ my-skill/ Markdown files that expand into prompts via `/name`. Simple text expansion with positional argument support (`$1`, `$2`, `$@`). ```markdown - + --- description: Review staged git changes --- @@ -80,8 +80,8 @@ Focus area: $1 Usage: `/review "error handling"` → expands with `$1` = "error handling" **Placement:** -- `~/.gsd/agent/prompts/` (global) -- `.gsd/prompts/` (project-local) +- `~/.sf/agent/prompts/` (global) +- `.sf/prompts/` (project-local) ### Themes @@ -90,7 +90,7 @@ JSON files defining the color palette for the TUI. Hot-reload: edit the file and **Built-in:** `dark`, `light` **Placement:** -- `~/.gsd/agent/themes/` (global) -- `.gsd/themes/` (project-local) +- `~/.sf/agent/themes/` (global) +- `.sf/themes/` (project-local) --- diff --git a/docs/dev/what-is-pi/10-providers-models-multi-model-by-default.md b/docs/dev/what-is-pi/10-providers-models-multi-model-by-default.md index f218ff10d..2002ca1a4 100644 --- a/docs/dev/what-is-pi/10-providers-models-multi-model-by-default.md +++ b/docs/dev/what-is-pi/10-providers-models-multi-model-by-default.md @@ -40,10 +40,10 @@ pi --list-models gemini # Search by name ### Custom Providers -Add providers via `~/.gsd/agent/models.json` (simple) or extensions (advanced with OAuth, custom streaming): +Add providers via `~/.sf/agent/models.json` (simple) or extensions (advanced with OAuth, custom streaming): ```json -// ~/.gsd/agent/models.json +// ~/.sf/agent/models.json { "providers": [{ "name": "my-proxy", diff --git a/docs/dev/what-is-pi/13-context-files-project-instructions.md b/docs/dev/what-is-pi/13-context-files-project-instructions.md index 822fb6ada..53335aa8f 100644 --- a/docs/dev/what-is-pi/13-context-files-project-instructions.md +++ b/docs/dev/what-is-pi/13-context-files-project-instructions.md @@ -5,7 +5,7 @@ Pi loads instruction files automatically at startup: ### AGENTS.md (or CLAUDE.md) Pi looks for `AGENTS.md` or `CLAUDE.md` in: -1. `~/.gsd/agent/AGENTS.md` (global) +1. `~/.sf/agent/AGENTS.md` (global) 2. Every parent directory from cwd up to filesystem root 3. Current directory @@ -14,12 +14,12 @@ All matching files are concatenated and included in the system prompt. Use these ### System Prompt Override Replace the default system prompt entirely: -- `.gsd/SYSTEM.md` (project) -- `~/.gsd/agent/SYSTEM.md` (global) +- `.sf/SYSTEM.md` (project) +- `~/.sf/agent/SYSTEM.md` (global) Append to it instead: -- `.gsd/APPEND_SYSTEM.md` (project) -- `~/.gsd/agent/APPEND_SYSTEM.md` (global) +- `.sf/APPEND_SYSTEM.md` (project) +- `~/.sf/agent/APPEND_SYSTEM.md` (global) ### File Arguments diff --git a/docs/dev/what-is-pi/19-building-branded-apps-on-top-of-pi.md b/docs/dev/what-is-pi/19-building-branded-apps-on-top-of-pi.md index ded6af0ba..9cbf15bf8 100644 --- a/docs/dev/what-is-pi/19-building-branded-apps-on-top-of-pi.md +++ b/docs/dev/what-is-pi/19-building-branded-apps-on-top-of-pi.md @@ -5,7 +5,7 @@ This document covers the part that the extension docs, SDK docs, RPC docs, and p **How do you build your own product on top of pi** so users run **your** app, **your** command, and **your** UI rather than installing and managing pi directly? Examples: -- a branded CLI like `gsd` +- a branded CLI like `sf` - a desktop app that uses pi as its backend engine - a web or Electron app that uses pi sessions, tools, and event streaming - an internal company agent product built on pi primitives @@ -14,7 +14,7 @@ The short answer is: - **Yes, you can build your own branded app on top of pi** - **No, end users do not need to install pi globally** if you ship your own app that depends on pi packages -- **No, you do not have to rely on `~/.gsd`** if you embed pi with custom paths and storage +- **No, you do not have to rely on `~/.sf`** if you embed pi with custom paths and storage - **Yes, you can bundle your own extensions, prompts, themes, skills, and providers** inside your app The rest of this document explains the architecture choices, storage choices, packaging strategies, and practical tradeoffs. @@ -59,7 +59,7 @@ You can ship your own app that depends on: That means a branded command like: ```bash -gsd +sf ``` can be **your** executable, backed by pi internals, without asking users to separately install and run `pi`. @@ -76,19 +76,19 @@ pi you can ship: ```bash -npm install -g my-gsd +npm install -g my-sf # or a standalone binary / packaged desktop app -gsd +sf ``` -And inside `gsd`, you import pi packages and create your own session, UI, storage, and resource loading behavior. +And inside `sf`, you import pi packages and create your own session, UI, storage, and resource loading behavior. --- -## 19.3 The Second Biggest Misconception: `~/.gsd` Is a Default, Not a Requirement +## 19.3 The Second Biggest Misconception: `~/.sf` Is a Default, Not a Requirement -Pi CLI defaults to `~/.gsd/agent`, but embedded applications are not forced to use it. +Pi CLI defaults to `~/.sf/agent`, but embedded applications are not forced to use it. When you use `createAgentSession()`, you can control: @@ -102,13 +102,13 @@ When you use `createAgentSession()`, you can control: That means your app can store state under: -- `~/.gsd/agent` +- `~/.sf/agent` - `~/Library/Application Support/SF` - `%APPDATA%/SF` - an app-local portable directory - a project-local directory -instead of `~/.gsd`. +instead of `~/.sf`. ### Things you can relocate @@ -138,7 +138,7 @@ Before writing code, decide which of these architectures you actually want. ### Architecture A: Branded Node CLI or TUI using the SDK -This is the most natural fit for tools like `gsd`. +This is the most natural fit for tools like `sf`. You create your own executable and call `createAgentSession()` directly. @@ -152,7 +152,7 @@ You create your own executable and call `createAgentSession()` directly. - type-safe - no subprocess management - easy to customize storage and discovery -- easiest way to remove dependency on `~/.gsd` +- easiest way to remove dependency on `~/.sf` - easiest way to bundle built-in resources #### Typical stack @@ -211,7 +211,7 @@ Use this decision table. | Goal | Best Starting Point | |------|---------------------| -| Branded CLI like `gsd` | `@mariozechner/pi-coding-agent` SDK | +| Branded CLI like `sf` | `@mariozechner/pi-coding-agent` SDK | | Branded TUI with coding tools | `@mariozechner/pi-coding-agent` SDK | | Desktop app with subprocess boundary | pi RPC mode | | Non-Node integration | pi RPC mode | @@ -234,12 +234,12 @@ Use this decision table. --- -## 19.6 The Recommended Path for a Branded CLI Like `gsd` +## 19.6 The Recommended Path for a Branded CLI Like `sf` If you want users to run: ```bash -gsd +sf ``` and you want it to feel like your product rather than "pi but renamed," the default recommendation is: @@ -269,7 +269,7 @@ A branded app should usually own its own storage hierarchy. Example: ```text -~/.gsd/ +~/.sf/ agent/ auth.json models.json @@ -291,7 +291,7 @@ Or on macOS: ### Why this matters -If your product uses `~/.gsd`, then: +If your product uses `~/.sf`, then: - it shares state with the user's pi installation - branding becomes muddy - support/debugging becomes more confusing @@ -312,7 +312,7 @@ import { SettingsManager, } from "@mariozechner/pi-coding-agent"; -const appRoot = path.join(os.homedir(), ".gsd"); +const appRoot = path.join(os.homedir(), ".sf"); const agentDir = path.join(appRoot, "agent"); const sessionsDir = path.join(appRoot, "sessions"); @@ -337,7 +337,7 @@ This is the core pattern for “my app uses pi, but not as global pi.” ## 19.8 Bundling Resources Inside Your App -This is another place where people often assume they must rely on discovery from `~/.gsd` or `.gsd/`. +This is another place where people often assume they must rely on discovery from `~/.sf` or `.sf/`. You do not. @@ -414,8 +414,8 @@ These are different product strategies. ### Discovery-driven product You intentionally load from: -- `~/.gsd/agent/...` -- `.gsd/...` +- `~/.sf/agent/...` +- `.sf/...` - installed pi packages #### Good when @@ -432,7 +432,7 @@ You intentionally ship your own resources and avoid implicit user-level discover - you do not want random user extensions affecting behavior ### Recommendation -For a branded tool like `gsd`, default to **bundled-app product** behavior. +For a branded tool like `sf`, default to **bundled-app product** behavior. If you later add plugin support, make it explicit. @@ -671,14 +671,14 @@ A branded app should decide whether users: Use custom `AuthStorage` paths. ```typescript -const authStorage = AuthStorage.create("/path/to/gsd/auth.json"); +const authStorage = AuthStorage.create("/path/to/sf/auth.json"); ``` ### App-owned model config Use your own `models.json` location or register providers dynamically. ```typescript -const modelRegistry = new ModelRegistry(authStorage, "/path/to/gsd/models.json"); +const modelRegistry = new ModelRegistry(authStorage, "/path/to/sf/models.json"); ``` ### Custom provider strategy @@ -688,12 +688,12 @@ That keeps the app experience aligned with your branding and infrastructure. --- -## 19.18 Building a Branded `gsd` CLI: Recommended Shape +## 19.18 Building a Branded `sf` CLI: Recommended Shape A practical architecture looks like this: ```text -my-gsd/ +my-sf/ package.json src/ cli.ts @@ -742,7 +742,7 @@ import { SettingsManager, } from "@mariozechner/pi-coding-agent"; -const appRoot = path.join(os.homedir(), ".gsd"); +const appRoot = path.join(os.homedir(), ".sf"); const agentDir = path.join(appRoot, "agent"); const sessionsDir = path.join(appRoot, "sessions"); @@ -809,14 +809,14 @@ For a white-labeled product, `InteractiveMode` is a good prototyping step, not a ## 19.21 What to Avoid in a Branded Product ### Avoid accidental dependence on ambient user state -If your app silently loads from a user's `~/.gsd`, you may get: +If your app silently loads from a user's `~/.sf`, you may get: - surprising extensions - strange prompts - odd themes - hard-to-debug behavior differences ### Avoid mixing branding and storage casually -If your app is called `gsd`, but state lives in `~/.gsd`, users will notice. +If your app is called `sf`, but state lives in `~/.sf`, users will notice. ### Avoid choosing RPC just because it sounds generic If your app is already Node/TypeScript, SDK embedding is usually simpler and more powerful. @@ -842,7 +842,7 @@ You do not need to expose: - Uses pi internally - App-owned directories and resources - Explicit plugins only -- Good for productized tools like `gsd` +- Good for productized tools like `sf` ### Posture C: “Custom agent product using pi primitives” - Uses `pi-agent-core` or selective libraries @@ -880,7 +880,7 @@ Then read the source package docs for exact API details: If your goal is: -> “I want users to download and run `gsd`, and have it use pi internally without requiring a separate pi install or `~/.gsd` setup.” +> “I want users to download and run `sf`, and have it use pi internally without requiring a separate pi install or `~/.sf` setup.” Then the answer is: diff --git a/docs/user-docs/auto-mode.md b/docs/user-docs/auto-mode.md index 055d888ba..1122922f6 100644 --- a/docs/user-docs/auto-mode.md +++ b/docs/user-docs/auto-mode.md @@ -1,10 +1,10 @@ # Auto Mode -Auto mode is SF's autonomous execution engine. Run `/gsd auto`, walk away, come back to built software with clean git history. +Auto mode is SF's autonomous execution engine. Run `/sf auto`, walk away, come back to built software with clean git history. ## How It Works -Auto mode is a **state machine driven by files on disk**. It reads `.gsd/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. +Auto mode is a **state machine driven by files on disk**. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. ### The Loop @@ -47,7 +47,7 @@ The amount of context inlined is controlled by your [token profile](./token-opti SF isolates milestone work using one of three modes (configured via `git.isolation` in preferences): -- **`worktree`** (default): Each milestone runs in its own git worktree at `.gsd/worktrees//` on a `milestone/` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit. +- **`worktree`** (default): Each milestone runs in its own git worktree at `.sf/worktrees//` on a `milestone/` branch. All slice work commits sequentially — no branch switching, no merge conflicts mid-milestone. When the milestone completes, it's squash-merged to main as one clean commit. - **`branch`**: Work happens in the project root on a `milestone/` branch. Useful for submodule-heavy repos where worktrees don't work well. - **`none`**: Work happens directly on your current branch. No worktree, no milestone branch. Ideal for hot-reload workflows where file isolation breaks dev tooling. @@ -59,9 +59,9 @@ When your project has independent milestones, you can run them simultaneously. E ### Crash Recovery -A lock file tracks the current unit. If the session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. +A lock file tracks the current unit. If the session dies, the next `/sf auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. -**Headless auto-restart (v2.26):** When running `gsd headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution. +**Headless auto-restart (v2.26):** When running `sf headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution. ### Provider Error Recovery @@ -95,16 +95,16 @@ The sliding-window approach reduces false positives on legitimate retries (e.g., ### Post-Mortem Investigation (v2.40) -`/gsd forensics` is a full-access SF debugger for post-mortem analysis of auto-mode failures. It provides: +`/sf forensics` is a full-access SF debugger for post-mortem analysis of auto-mode failures. It provides: - **Anomaly detection** — structured identification of stuck loops, cost spikes, timeouts, missing artifacts, and crashes with severity levels - **Unit traces** — last 10 unit executions with error details and execution times - **Metrics analysis** — cost, token counts, and execution time breakdowns -- **Doctor integration** — includes structural health issues from `/gsd doctor` +- **Doctor integration** — includes structural health issues from `/sf doctor` - **LLM-guided investigation** — an agent session with full tool access to investigate root causes ``` -/gsd forensics [optional problem description] +/sf forensics [optional problem description] ``` See [Troubleshooting](./troubleshooting.md) for more on diagnosing issues. @@ -164,13 +164,13 @@ Auto-mode pauses before each slice, presenting the slice context for discussion. ### HTML Reports (v2.26) -After a milestone completes, SF auto-generates a self-contained HTML report in `.gsd/reports/`. Reports include project summary, progress tree, slice dependency graph (SVG DAG), cost/token metrics with bar charts, execution timeline, changelog, and knowledge base. No external dependencies — all CSS and JS are inlined. +After a milestone completes, SF auto-generates a self-contained HTML report in `.sf/reports/`. Reports include project summary, progress tree, slice dependency graph (SVG DAG), cost/token metrics with bar charts, execution timeline, changelog, and knowledge base. No external dependencies — all CSS and JS are inlined. ```yaml auto_report: true # enabled by default ``` -Generate manually anytime with `/gsd export --html`, or generate reports for all milestones at once with `/gsd export --html --all` (v2.28). +Generate manually anytime with `/sf export --html`, or generate reports for all milestones at once with `/sf export --html --all` (v2.28). ### Failure Recovery (v2.28) @@ -190,7 +190,7 @@ This linear flow is easier to debug, uses less memory (no recursive call stack), ### Real-Time Health Visibility (v2.40) -Doctor issues (from `/gsd doctor`) now surface in real time across three places: +Doctor issues (from `/sf doctor`) now surface in real time across three places: - **Dashboard widget** — health indicator with issue count and severity - **Workflow visualizer** — issues shown in the status panel @@ -213,7 +213,7 @@ See [Configuration](./configuration.md) for skill routing preferences. ### Start ``` -/gsd auto +/sf auto ``` ### Pause @@ -223,7 +223,7 @@ Press **Escape**. The conversation is preserved. You can interact with the agent ### Resume ``` -/gsd auto +/sf auto ``` Auto mode reads disk state and picks up where it left off. @@ -231,7 +231,7 @@ Auto mode reads disk state and picks up where it left off. ### Stop ``` -/gsd stop +/sf stop ``` Stops auto mode gracefully. Can be run from a different terminal. @@ -239,7 +239,7 @@ Stops auto mode gracefully. Can be run from a different terminal. ### Steer ``` -/gsd steer +/sf steer ``` Hard-steer plan documents during execution without stopping the pipeline. Changes are picked up at the next phase boundary. @@ -247,7 +247,7 @@ Hard-steer plan documents during execution without stopping the pipeline. Change ### Capture ``` -/gsd capture "add rate limiting to API endpoints" +/sf capture "add rate limiting to API endpoints" ``` Fire-and-forget thought capture. Captures are triaged automatically between tasks. See [Captures & Triage](./captures-triage.md). @@ -255,14 +255,14 @@ Fire-and-forget thought capture. Captures are triaged automatically between task ### Visualize ``` -/gsd visualize +/sf visualize ``` Open the workflow visualizer — interactive tabs for progress, dependencies, metrics, and timeline. See [Workflow Visualizer](./visualizer.md). ## Dashboard -`Ctrl+Alt+G` or `/gsd status` shows real-time progress: +`Ctrl+Alt+G` or `/sf status` shows real-time progress: - Current milestone, slice, and task - Auto mode elapsed time and phase diff --git a/docs/user-docs/captures-triage.md b/docs/user-docs/captures-triage.md index a5913b9e0..04c1331c7 100644 --- a/docs/user-docs/captures-triage.md +++ b/docs/user-docs/captures-triage.md @@ -9,11 +9,11 @@ Captures let you fire-and-forget thoughts during auto-mode execution. Instead of While auto-mode is running (or any time): ``` -/gsd capture "add rate limiting to the API endpoints" -/gsd capture "the auth flow should support OAuth, not just JWT" +/sf capture "add rate limiting to the API endpoints" +/sf capture "the auth flow should support OAuth, not just JWT" ``` -Captures are appended to `.gsd/CAPTURES.md` and triaged automatically between tasks. +Captures are appended to `.sf/CAPTURES.md` and triaged automatically between tasks. ## How It Works @@ -23,7 +23,7 @@ Captures are appended to `.gsd/CAPTURES.md` and triaged automatically between ta capture → triage → confirm → resolve → resume ``` -1. **Capture** — `/gsd capture "thought"` appends to `.gsd/CAPTURES.md` with a timestamp and unique ID +1. **Capture** — `/sf capture "thought"` appends to `.sf/CAPTURES.md` with a timestamp and unique ID 2. **Triage** — at natural seams between tasks (in `handleAgentEnd`), SF detects pending captures and classifies them 3. **Confirm** — the user is shown the proposed resolution and confirms or adjusts 4. **Resolve** — the resolution is applied (task injection, replan trigger, deferral, etc.) @@ -55,7 +55,7 @@ The LLM classifies each capture and proposes a resolution. Plan-modifying resolu Trigger triage manually at any time: ``` -/gsd triage +/sf triage ``` This is useful when you've accumulated several captures and want to process them before the next natural seam. @@ -72,11 +72,11 @@ Capture context is automatically injected into: ## Worktree Awareness -Captures always resolve to the **original project root's** `.gsd/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the auto-mode session running in a worktree. +Captures always resolve to the **original project root's** `.sf/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the auto-mode session running in a worktree. ## Commands | Command | Description | |---------|-------------| -| `/gsd capture "text"` | Capture a thought (quotes optional for single words) | -| `/gsd triage` | Manually trigger triage of pending captures | +| `/sf capture "text"` | Capture a thought (quotes optional for single words) | +| `/sf triage` | Manually trigger triage of pending captures | diff --git a/docs/user-docs/commands.md b/docs/user-docs/commands.md index 85b14c8e0..88e7bae06 100644 --- a/docs/user-docs/commands.md +++ b/docs/user-docs/commands.md @@ -4,78 +4,78 @@ | Command | Description | |---------|-------------| -| `/gsd` | Step mode — execute one unit at a time, pause between each | -| `/gsd next` | Explicit step mode (same as `/gsd`) | -| `/gsd auto` | Autonomous mode — research, plan, execute, commit, repeat | -| `/gsd quick` | Execute a quick task with SF guarantees (atomic commits, state tracking) without full planning overhead | -| `/gsd stop` | Stop auto mode gracefully | -| `/gsd pause` | Pause auto-mode (preserves state, `/gsd auto` to resume) | -| `/gsd steer` | Hard-steer plan documents during execution | -| `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | -| `/gsd status` | Progress dashboard | -| `/gsd widget` | Cycle dashboard widget: full / small / min / off | -| `/gsd queue` | Queue and reorder future milestones (safe during auto mode) | -| `/gsd capture` | Fire-and-forget thought capture (works during auto mode) | -| `/gsd triage` | Manually trigger triage of pending captures | -| `/gsd dispatch` | Dispatch a specific phase directly (research, plan, execute, complete, reassess, uat, replan) | -| `/gsd history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | -| `/gsd forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for auto-mode failures | -| `/gsd cleanup` | Clean up SF state files and stale worktrees | -| `/gsd visualize` | Open workflow visualizer (progress, deps, metrics, timeline) | -| `/gsd export --html` | Generate self-contained HTML report for current or completed milestone | -| `/gsd export --html --all` | Generate retrospective reports for all milestones at once | -| `/gsd update` | Update SF to the latest version in-session | -| `/gsd knowledge` | Add persistent project knowledge (rule, pattern, or lesson) | -| `/gsd fast` | Toggle service tier for supported models (prioritized API routing) | -| `/gsd rate` | Rate last unit's model tier (over/ok/under) — improves adaptive routing | -| `/gsd changelog` | Show categorized release notes | -| `/gsd logs` | Browse activity logs, debug logs, and metrics | -| `/gsd remote` | Control remote auto-mode | -| `/gsd help` | Categorized command reference with descriptions for all SF subcommands | +| `/sf` | Step mode — execute one unit at a time, pause between each | +| `/sf next` | Explicit step mode (same as `/sf`) | +| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat | +| `/sf quick` | Execute a quick task with SF guarantees (atomic commits, state tracking) without full planning overhead | +| `/sf stop` | Stop auto mode gracefully | +| `/sf pause` | Pause auto-mode (preserves state, `/sf auto` to resume) | +| `/sf steer` | Hard-steer plan documents during execution | +| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) | +| `/sf status` | Progress dashboard | +| `/sf widget` | Cycle dashboard widget: full / small / min / off | +| `/sf queue` | Queue and reorder future milestones (safe during auto mode) | +| `/sf capture` | Fire-and-forget thought capture (works during auto mode) | +| `/sf triage` | Manually trigger triage of pending captures | +| `/sf dispatch` | Dispatch a specific phase directly (research, plan, execute, complete, reassess, uat, replan) | +| `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | +| `/sf forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for auto-mode failures | +| `/sf cleanup` | Clean up SF state files and stale worktrees | +| `/sf visualize` | Open workflow visualizer (progress, deps, metrics, timeline) | +| `/sf export --html` | Generate self-contained HTML report for current or completed milestone | +| `/sf export --html --all` | Generate retrospective reports for all milestones at once | +| `/sf update` | Update SF to the latest version in-session | +| `/sf knowledge` | Add persistent project knowledge (rule, pattern, or lesson) | +| `/sf fast` | Toggle service tier for supported models (prioritized API routing) | +| `/sf rate` | Rate last unit's model tier (over/ok/under) — improves adaptive routing | +| `/sf changelog` | Show categorized release notes | +| `/sf logs` | Browse activity logs, debug logs, and metrics | +| `/sf remote` | Control remote auto-mode | +| `/sf help` | Categorized command reference with descriptions for all SF subcommands | ## Configuration & Diagnostics | Command | Description | |---------|-------------| -| `/gsd prefs` | Model selection, timeouts, budget ceiling | -| `/gsd mode` | Switch workflow mode (solo/team) with coordinated defaults for milestone IDs, git commit behavior, and documentation | -| `/gsd config` | Re-run the provider setup wizard (LLM provider + tool keys) | -| `/gsd keys` | API key manager — list, add, remove, test, rotate, doctor | -| `/gsd doctor` | Runtime health checks with auto-fix — issues surface in real time across widget, visualizer, and HTML reports (v2.40) | -| `/gsd inspect` | Show SQLite DB diagnostics | -| `/gsd init` | Project init wizard — detect, configure, bootstrap `.gsd/` | -| `/gsd setup` | Global setup status and configuration | -| `/gsd skill-health` | Skill lifecycle dashboard — usage stats, success rates, token trends, staleness warnings | -| `/gsd skill-health ` | Detailed view for a single skill | -| `/gsd skill-health --declining` | Show only skills flagged for declining performance | -| `/gsd skill-health --stale N` | Show skills unused for N+ days | -| `/gsd hooks` | Show configured post-unit and pre-dispatch hooks | -| `/gsd run-hook` | Manually trigger a specific hook | -| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | +| `/sf prefs` | Model selection, timeouts, budget ceiling | +| `/sf mode` | Switch workflow mode (solo/team) with coordinated defaults for milestone IDs, git commit behavior, and documentation | +| `/sf config` | Re-run the provider setup wizard (LLM provider + tool keys) | +| `/sf keys` | API key manager — list, add, remove, test, rotate, doctor | +| `/sf doctor` | Runtime health checks with auto-fix — issues surface in real time across widget, visualizer, and HTML reports (v2.40) | +| `/sf inspect` | Show SQLite DB diagnostics | +| `/sf init` | Project init wizard — detect, configure, bootstrap `.sf/` | +| `/sf setup` | Global setup status and configuration | +| `/sf skill-health` | Skill lifecycle dashboard — usage stats, success rates, token trends, staleness warnings | +| `/sf skill-health ` | Detailed view for a single skill | +| `/sf skill-health --declining` | Show only skills flagged for declining performance | +| `/sf skill-health --stale N` | Show skills unused for N+ days | +| `/sf hooks` | Show configured post-unit and pre-dispatch hooks | +| `/sf run-hook` | Manually trigger a specific hook | +| `/sf migrate` | Migrate a v1 `.planning` directory to `.sf` format | ## Milestone Management | Command | Description | |---------|-------------| -| `/gsd new-milestone` | Create a new milestone | -| `/gsd skip` | Prevent a unit from auto-mode dispatch | -| `/gsd undo` | Revert last completed unit | -| `/gsd undo-task` | Reset a specific task's completion state (DB + markdown) | -| `/gsd reset-slice` | Reset a slice and all its tasks (DB + markdown) | -| `/gsd park` | Park a milestone — skip without deleting | -| `/gsd unpark` | Reactivate a parked milestone | -| Discard milestone | Available via `/gsd` wizard → "Milestone actions" → "Discard" | +| `/sf new-milestone` | Create a new milestone | +| `/sf skip` | Prevent a unit from auto-mode dispatch | +| `/sf undo` | Revert last completed unit | +| `/sf undo-task` | Reset a specific task's completion state (DB + markdown) | +| `/sf reset-slice` | Reset a slice and all its tasks (DB + markdown) | +| `/sf park` | Park a milestone — skip without deleting | +| `/sf unpark` | Reactivate a parked milestone | +| Discard milestone | Available via `/sf` wizard → "Milestone actions" → "Discard" | ## Parallel Orchestration | Command | Description | |---------|-------------| -| `/gsd parallel start` | Analyze eligibility, confirm, and start workers | -| `/gsd parallel status` | Show all workers with state, progress, and cost | -| `/gsd parallel stop [MID]` | Stop all workers or a specific milestone's worker | -| `/gsd parallel pause [MID]` | Pause all workers or a specific one | -| `/gsd parallel resume [MID]` | Resume paused workers | -| `/gsd parallel merge [MID]` | Merge completed milestones back to main | +| `/sf parallel start` | Analyze eligibility, confirm, and start workers | +| `/sf parallel status` | Show all workers with state, progress, and cost | +| `/sf parallel stop [MID]` | Stop all workers or a specific milestone's worker | +| `/sf parallel pause [MID]` | Pause all workers or a specific one | +| `/sf parallel resume [MID]` | Resume paused workers | +| `/sf parallel merge [MID]` | Merge completed milestones back to main | See [Parallel Orchestration](./parallel-orchestration.md) for full documentation. @@ -83,50 +83,50 @@ See [Parallel Orchestration](./parallel-orchestration.md) for full documentation | Command | Description | |---------|-------------| -| `/gsd start` | Start a workflow template (bugfix, spike, feature, hotfix, refactor, security-audit, dep-upgrade, full-project) | -| `/gsd start resume` | Resume an in-progress workflow | -| `/gsd templates` | List available workflow templates | -| `/gsd templates info ` | Show detailed template info | +| `/sf start` | Start a workflow template (bugfix, spike, feature, hotfix, refactor, security-audit, dep-upgrade, full-project) | +| `/sf start resume` | Resume an in-progress workflow | +| `/sf templates` | List available workflow templates | +| `/sf templates info ` | Show detailed template info | ## Custom Workflows (v2.42) | Command | Description | |---------|-------------| -| `/gsd workflow new` | Create a new workflow definition (via skill) | -| `/gsd workflow run ` | Create a run and start auto-mode | -| `/gsd workflow list` | List workflow runs | -| `/gsd workflow validate ` | Validate a workflow definition YAML | -| `/gsd workflow pause` | Pause custom workflow auto-mode | -| `/gsd workflow resume` | Resume paused custom workflow auto-mode | +| `/sf workflow new` | Create a new workflow definition (via skill) | +| `/sf workflow run ` | Create a run and start auto-mode | +| `/sf workflow list` | List workflow runs | +| `/sf workflow validate ` | Validate a workflow definition YAML | +| `/sf workflow pause` | Pause custom workflow auto-mode | +| `/sf workflow resume` | Resume paused custom workflow auto-mode | ## Extensions | Command | Description | |---------|-------------| -| `/gsd extensions list` | List all extensions and their status | -| `/gsd extensions enable ` | Enable a disabled extension | -| `/gsd extensions disable ` | Disable an extension | -| `/gsd extensions info ` | Show extension details | +| `/sf extensions list` | List all extensions and their status | +| `/sf extensions enable ` | Enable a disabled extension | +| `/sf extensions disable ` | Disable an extension | +| `/sf extensions info ` | Show extension details | ## cmux Integration | Command | Description | |---------|-------------| -| `/gsd cmux status` | Show cmux detection, prefs, and capabilities | -| `/gsd cmux on` | Enable cmux integration | -| `/gsd cmux off` | Disable cmux integration | -| `/gsd cmux notifications on/off` | Toggle cmux desktop notifications | -| `/gsd cmux sidebar on/off` | Toggle cmux sidebar metadata | -| `/gsd cmux splits on/off` | Toggle cmux visual subagent splits | +| `/sf cmux status` | Show cmux detection, prefs, and capabilities | +| `/sf cmux on` | Enable cmux integration | +| `/sf cmux off` | Disable cmux integration | +| `/sf cmux notifications on/off` | Toggle cmux desktop notifications | +| `/sf cmux sidebar on/off` | Toggle cmux sidebar metadata | +| `/sf cmux splits on/off` | Toggle cmux visual subagent splits | ## GitHub Sync (v2.39) | Command | Description | |---------|-------------| -| `/github-sync bootstrap` | Initial setup — creates GitHub Milestones, Issues, and draft PRs from current `.gsd/` state | +| `/github-sync bootstrap` | Initial setup — creates GitHub Milestones, Issues, and draft PRs from current `.sf/` state | | `/github-sync status` | Show sync mapping counts (milestones, slices, tasks) | -Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed and authenticated. Sync mapping is persisted in `.gsd/.github-sync.json`. +Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed and authenticated. Sync mapping is persisted in `.sf/.github-sync.json`. ## Git Commands @@ -164,54 +164,54 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a | Flag | Description | |------|-------------| -| `gsd` | Start a new interactive session | -| `gsd --continue` (`-c`) | Resume the most recent session for the current directory | -| `gsd --model ` | Override the default model for this session | -| `gsd --print "msg"` (`-p`) | Single-shot prompt mode (no TUI) | -| `gsd --mode ` | Output mode for non-interactive use | -| `gsd --list-models [search]` | List available models and exit | -| `gsd --web [path]` | Start browser-based web interface (optional project path) | -| `gsd --worktree` (`-w`) [name] | Start session in a git worktree (auto-generates name if omitted) | -| `gsd --no-session` | Disable session persistence | -| `gsd --extension ` | Load an additional extension (can be repeated) | -| `gsd --append-system-prompt ` | Append text to the system prompt | -| `gsd --tools ` | Comma-separated list of tools to enable | -| `gsd --version` (`-v`) | Print version and exit | -| `gsd --help` (`-h`) | Print help and exit | -| `gsd sessions` | Interactive session picker — list all saved sessions for the current directory and choose one to resume | -| `gsd --debug` | Enable structured JSONL diagnostic logging for troubleshooting dispatch and state issues | -| `gsd config` | Set up global API keys for search and docs tools (saved to `~/.gsd/agent/auth.json`, applies to all projects). See [Global API Keys](./configuration.md#global-api-keys-gsd-config). | -| `gsd update` | Update SF to the latest version | -| `gsd headless new-milestone` | Create a new milestone from a context file (headless — no TUI required) | +| `sf` | Start a new interactive session | +| `sf --continue` (`-c`) | Resume the most recent session for the current directory | +| `sf --model ` | Override the default model for this session | +| `sf --print "msg"` (`-p`) | Single-shot prompt mode (no TUI) | +| `sf --mode ` | Output mode for non-interactive use | +| `sf --list-models [search]` | List available models and exit | +| `sf --web [path]` | Start browser-based web interface (optional project path) | +| `sf --worktree` (`-w`) [name] | Start session in a git worktree (auto-generates name if omitted) | +| `sf --no-session` | Disable session persistence | +| `sf --extension ` | Load an additional extension (can be repeated) | +| `sf --append-system-prompt ` | Append text to the system prompt | +| `sf --tools ` | Comma-separated list of tools to enable | +| `sf --version` (`-v`) | Print version and exit | +| `sf --help` (`-h`) | Print help and exit | +| `sf sessions` | Interactive session picker — list all saved sessions for the current directory and choose one to resume | +| `sf --debug` | Enable structured JSONL diagnostic logging for troubleshooting dispatch and state issues | +| `sf config` | Set up global API keys for search and docs tools (saved to `~/.sf/agent/auth.json`, applies to all projects). See [Global API Keys](./configuration.md#global-api-keys-sf-config). | +| `sf update` | Update SF to the latest version | +| `sf headless new-milestone` | Create a new milestone from a context file (headless — no TUI required) | ## Headless Mode -`gsd headless` runs `/gsd` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes. +`sf headless` runs `/sf` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes. ```bash # Run auto mode (default) -gsd headless +sf headless # Run a single unit -gsd headless next +sf headless next # Instant JSON snapshot — no LLM, ~50ms -gsd headless query +sf headless query # With timeout for CI -gsd headless --timeout 600000 auto +sf headless --timeout 600000 auto # Force a specific phase -gsd headless dispatch plan +sf headless dispatch plan # Create a new milestone from a context file and start auto mode -gsd headless new-milestone --context brief.md --auto +sf headless new-milestone --context brief.md --auto # Create a milestone from inline text -gsd headless new-milestone --context-text "Build a REST API with auth" +sf headless new-milestone --context-text "Build a REST API with auth" # Pipe context from stdin -echo "Build a CLI tool" | gsd headless new-milestone --context - +echo "Build a CLI tool" | sf headless new-milestone --context - ``` | Flag | Description | @@ -226,20 +226,20 @@ echo "Build a CLI tool" | gsd headless new-milestone --context - **Exit codes:** `0` = complete, `1` = error or timeout, `2` = blocked. -Any `/gsd` subcommand works as a positional argument — `gsd headless status`, `gsd headless doctor`, `gsd headless dispatch execute`, etc. +Any `/sf` subcommand works as a positional argument — `sf headless status`, `sf headless doctor`, `sf headless dispatch execute`, etc. -### `gsd headless query` +### `sf headless query` Returns a single JSON object with the full project snapshot — no LLM session, no RPC child, instant response (~50ms). This is the recommended way for orchestrators and scripts to inspect SF state. ```bash -gsd headless query | jq '.state.phase' +sf headless query | jq '.state.phase' # "executing" -gsd headless query | jq '.next' +sf headless query | jq '.next' # {"action":"dispatch","unitType":"execute-task","unitId":"M001/S01/T03"} -gsd headless query | jq '.cost.total' +sf headless query | jq '.cost.total' # 4.25 ``` @@ -270,21 +270,21 @@ gsd headless query | jq '.cost.total' ## MCP Server Mode -`gsd --mode mcp` runs SF as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all SF tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host. +`sf --mode mcp` runs SF as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all SF tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host. ```bash # Start SF as an MCP server -gsd --mode mcp +sf --mode mcp ``` The server registers all tools from the agent session and maps MCP `tools/list` and `tools/call` requests to SF tool definitions. It runs until the transport closes. ## In-Session Update -`/gsd update` checks npm for a newer version of SF and installs it without leaving the session. +`/sf update` checks npm for a newer version of SF and installs it without leaving the session. ```bash -/gsd update +/sf update # Current version: v2.36.0 # Checking npm registry... # Updated to v2.37.0. Restart SF to use the new version. @@ -294,14 +294,14 @@ If already up to date, it reports so and takes no action. ## Export -`/gsd export` generates reports of milestone work. +`/sf export` generates reports of milestone work. ```bash # Generate HTML report for the active milestone -/gsd export --html +/sf export --html # Generate retrospective reports for ALL milestones at once -/gsd export --html --all +/sf export --html --all ``` -Reports are saved to `.gsd/reports/` with a browseable `index.html` that links to all generated snapshots. +Reports are saved to `.sf/reports/` with a browseable `index.html` that links to all generated snapshots. diff --git a/docs/user-docs/configuration.md b/docs/user-docs/configuration.md index 151f1e4d1..8d3427961 100644 --- a/docs/user-docs/configuration.md +++ b/docs/user-docs/configuration.md @@ -1,20 +1,20 @@ # Configuration -SF preferences live in `~/.gsd/PREFERENCES.md` (global) or `.gsd/PREFERENCES.md` (project-local). Manage interactively with `/gsd prefs`. +SF preferences live in `~/.sf/PREFERENCES.md` (global) or `.sf/PREFERENCES.md` (project-local). Manage interactively with `/sf prefs`. -## `/gsd prefs` Commands +## `/sf prefs` Commands | Command | Description | |---------|-------------| -| `/gsd prefs` | Open the global preferences wizard (default) | -| `/gsd prefs global` | Interactive wizard for global preferences (`~/.gsd/PREFERENCES.md`) | -| `/gsd prefs project` | Interactive wizard for project preferences (`.gsd/PREFERENCES.md`) | -| `/gsd prefs status` | Show current preference files, merged values, and skill resolution status | -| `/gsd prefs wizard` | Alias for `/gsd prefs global` | -| `/gsd prefs setup` | Alias for `/gsd prefs wizard` — creates preferences file if missing | -| `/gsd prefs import-claude` | Import Claude marketplace plugins and skills as namespaced SF components | -| `/gsd prefs import-claude global` | Import to global scope | -| `/gsd prefs import-claude project` | Import to project scope | +| `/sf prefs` | Open the global preferences wizard (default) | +| `/sf prefs global` | Interactive wizard for global preferences (`~/.sf/PREFERENCES.md`) | +| `/sf prefs project` | Interactive wizard for project preferences (`.sf/PREFERENCES.md`) | +| `/sf prefs status` | Show current preference files, merged values, and skill resolution status | +| `/sf prefs wizard` | Alias for `/sf prefs global` | +| `/sf prefs setup` | Alias for `/sf prefs wizard` — creates preferences file if missing | +| `/sf prefs import-claude` | Import Claude marketplace plugins and skills as namespaced SF components | +| `/sf prefs import-claude global` | Import to global scope | +| `/sf prefs import-claude project` | Import to project scope | ## Preferences File Format @@ -42,20 +42,20 @@ token_profile: balanced | Scope | Path | Applies to | |-------|------|-----------| -| Global | `~/.gsd/PREFERENCES.md` | All projects | -| Project | `.gsd/PREFERENCES.md` | Current project only | +| Global | `~/.sf/PREFERENCES.md` | All projects | +| Project | `.sf/PREFERENCES.md` | Current project only | **Merge behavior:** - **Scalar fields** (`skill_discovery`, `budget_ceiling`): project wins if defined - **Array fields** (`always_use_skills`, etc.): concatenated (global first, then project) - **Object fields** (`models`, `git`, `auto_supervisor`): shallow-merged, project overrides per-key -## Global API Keys (`/gsd config`) +## Global API Keys (`/sf config`) -Tool API keys are stored globally in `~/.gsd/agent/auth.json` and apply to all projects automatically. Set them once with `/gsd config` — no need to configure per-project `.env` files. +Tool API keys are stored globally in `~/.sf/agent/auth.json` and apply to all projects automatically. Set them once with `/sf config` — no need to configure per-project `.env` files. ```bash -/gsd config +/sf config ``` This opens an interactive wizard showing which keys are configured and which are missing. Select a tool to enter its key. @@ -70,7 +70,7 @@ This opens an interactive wizard showing which keys are configured and which are ### How it works -1. `/gsd config` saves keys to `~/.gsd/agent/auth.json` +1. `/sf config` saves keys to `~/.sf/agent/auth.json` 2. On every session start, `loadToolApiKeys()` reads the file and sets environment variables 3. Keys apply to all projects — no per-project setup required 4. Environment variables (`export BRAVE_API_KEY=...`) take precedence over saved keys @@ -85,12 +85,12 @@ SF can connect to external MCP servers configured in project files. This is usef SF reads MCP client configuration from these project-local paths: - `.mcp.json` -- `.gsd/mcp.json` +- `.sf/mcp.json` If both files exist, server names are merged and the first definition found wins. Use: - `.mcp.json` for repo-shared MCP configuration you may want to commit -- `.gsd/mcp.json` for local-only MCP configuration you do **not** want to share +- `.sf/mcp.json` for local-only MCP configuration you do **not** want to share ### Supported transports @@ -148,15 +148,15 @@ Recommended verification order: - Use absolute paths for local executables and scripts when possible. - For `stdio` servers, prefer setting required environment variables directly in the MCP config instead of relying on an interactive shell profile. -- SF and `gsd-mcp-server` both hydrate supported model and tool keys saved in `~/.gsd/agent/auth.json`, so MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials. +- SF and `sf-mcp-server` both hydrate supported model and tool keys saved in `~/.sf/agent/auth.json`, so MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials. - If a server is team-shared and safe to commit, `.mcp.json` is usually the better home. -- If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.gsd/mcp.json`. +- If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.sf/mcp.json`. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `SF_HOME` | `~/.gsd` | Global SF directory. All paths derive from this unless individually overridden. Affects preferences, skills, sessions, and per-project state. (v2.39) | +| `SF_HOME` | `~/.sf` | Global SF directory. All paths derive from this unless individually overridden. Affects preferences, skills, sessions, and per-project state. (v2.39) | | `SF_PROJECT_ID` | (auto-hash) | Override the automatic project identity hash. Per-project state goes to `$SF_HOME/projects//` instead of the computed hash. Useful for CI/CD or sharing state across clones of the same repo. (v2.39) | | `SF_STATE_DIR` | `$SF_HOME` | Per-project state root. Controls where `projects//` directories are created. Takes precedence over `SF_HOME` for project state. | | `SF_CODING_AGENT_DIR` | `$SF_HOME/agent` | Agent directory containing managed resources, extensions, and auth. Takes precedence over `SF_HOME` for agent paths. | @@ -191,12 +191,12 @@ models: ### Custom Model Definitions (`models.json`) -Define custom models and providers in `~/.gsd/agent/models.json`. This lets you add models not included in the default registry — useful for self-hosted endpoints (Ollama, vLLM, LM Studio), fine-tuned models, proxies, or new provider releases. +Define custom models and providers in `~/.sf/agent/models.json`. This lets you add models not included in the default registry — useful for self-hosted endpoints (Ollama, vLLM, LM Studio), fine-tuned models, proxies, or new provider releases. SF resolves models.json with fallback logic: -1. `~/.gsd/agent/models.json` — primary (SF) +1. `~/.sf/agent/models.json` — primary (SF) 2. `~/.pi/agent/models.json` — fallback (Pi) -3. If neither exists, creates `~/.gsd/agent/models.json` +3. If neither exists, creates `~/.sf/agent/models.json` **Quick example for local models (Ollama):** @@ -240,7 +240,7 @@ For providers not built into SF, community extensions can add full provider supp | Extension | Provider | Models | Install | |-----------|----------|--------|---------| -| [`pi-dashscope`](https://www.npmjs.com/package/pi-dashscope) | Alibaba DashScope (ModelStudio) | Qwen3, GLM-5, MiniMax M2.5, Kimi K2.5 | `gsd install npm:pi-dashscope` | +| [`pi-dashscope`](https://www.npmjs.com/package/pi-dashscope) | Alibaba DashScope (ModelStudio) | Qwen3, GLM-5, MiniMax M2.5, Kimi K2.5 | `sf install npm:pi-dashscope` | Community extensions are recommended over the built-in `alibaba-coding-plan` provider for DashScope models — they use the correct OpenAI-compatible endpoint and include per-model compatibility flags for thinking mode. @@ -368,7 +368,7 @@ Public URLs (`https://example.com`, `http://8.8.8.8`) are not affected. **Allowing specific internal hosts:** -If you need the agent to fetch from internal URLs (self-hosted docs, internal APIs behind a VPN), add their hostnames to `fetchAllowedUrls` in global settings (`~/.gsd/agent/settings.json`): +If you need the agent to fetch from internal URLs (self-hosted docs, internal APIs behind a VPN), add their hostnames to `fetchAllowedUrls` in global settings (`~/.sf/agent/settings.json`): ```json { @@ -394,7 +394,7 @@ Auto-generate HTML reports after milestone completion: auto_report: true # default: true ``` -Reports are written to `.gsd/reports/` as self-contained HTML files with embedded CSS/JS. +Reports are written to `.sf/reports/` as self-contained HTML files with embedded CSS/JS. ### `unique_milestone_ids` @@ -420,9 +420,9 @@ git: main_branch: main # primary branch name merge_strategy: squash # how worktree branches merge: "squash" or "merge" isolation: worktree # git isolation: "worktree", "branch", or "none" - commit_docs: true # commit .gsd/ artifacts to git (set false to keep local) + commit_docs: true # commit .sf/ artifacts to git (set false to keep local) manage_gitignore: true # set false to prevent SF from modifying .gitignore - worktree_post_create: .gsd/hooks/post-worktree-create # script to run after worktree creation + worktree_post_create: .sf/hooks/post-worktree-create # script to run after worktree creation auto_pr: false # create a PR on milestone completion (requires push_branches) pr_target_branch: develop # target branch for auto-created PRs (default: main branch) ``` @@ -438,7 +438,7 @@ git: | `main_branch` | string | `"main"` | Primary branch name | | `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) | | `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) | -| `commit_docs` | boolean | `true` | Commit `.gsd/` planning artifacts to git. Set `false` to keep local-only | +| `commit_docs` | boolean | `true` | Commit `.sf/` planning artifacts to git. Set `false` to keep local-only | | `manage_gitignore` | boolean | `true` | When `false`, SF will not modify `.gitignore` at all — no baseline patterns, no self-healing. Use if you manage your own `.gitignore` | | `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars | | `auto_pr` | boolean | `false` | Automatically create a pull request when a milestone completes. Requires `auto_push: true` and `gh` CLI installed and authenticated | @@ -450,14 +450,14 @@ Script to run after a worktree is created (both auto-mode and manual `/worktree` ```yaml git: - worktree_post_create: .gsd/hooks/post-worktree-create + worktree_post_create: .sf/hooks/post-worktree-create ``` The script receives two environment variables: - `SOURCE_DIR` — the original project root - `WORKTREE_DIR` — the newly created worktree path -Example hook script (`.gsd/hooks/post-worktree-create`): +Example hook script (`.sf/hooks/post-worktree-create`): ```bash #!/bin/bash @@ -500,7 +500,7 @@ GitHub sync configuration. When enabled, SF auto-syncs milestones, slices, and t github: enabled: true repo: "owner/repo" # auto-detected from git remote if omitted - labels: [gsd, auto-generated] # labels applied to created issues/PRs + labels: [sf, auto-generated] # labels applied to created issues/PRs project: "Project ID" # optional GitHub Project board ``` @@ -513,7 +513,7 @@ github: **Requirements:** - `gh` CLI installed and authenticated (`gh auth login`) -- Sync mapping is persisted in `.gsd/.github-sync.json` +- Sync mapping is persisted in `.sf/.github-sync.json` - Rate-limit aware — skips sync when GitHub API rate limit is low **Commands:** @@ -652,13 +652,13 @@ custom_instructions: - "Prefer functional patterns over classes" ``` -For project-specific knowledge (patterns, gotchas, lessons learned), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically. Add entries with `/gsd knowledge rule|pattern|lesson `. +For project-specific knowledge (patterns, gotchas, lessons learned), use `.sf/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically. Add entries with `/sf knowledge rule|pattern|lesson `. ### `RUNTIME.md` — Runtime Context (v2.39) -Declare project-level runtime context in `.gsd/RUNTIME.md`. This file is inlined into task execution prompts, giving the agent accurate information about your runtime environment without relying on hallucinated paths or URLs. +Declare project-level runtime context in `.sf/RUNTIME.md`. This file is inlined into task execution prompts, giving the agent accurate information about your runtime environment without relying on hallucinated paths or URLs. -**Location:** `.gsd/RUNTIME.md` +**Location:** `.sf/RUNTIME.md` **Example:** @@ -711,7 +711,7 @@ context_management: ### `service_tier` (v2.42) -OpenAI service tier preference for supported models. Toggle with `/gsd fast`. +OpenAI service tier preference for supported models. Toggle with `/sf fast`. | Value | Behavior | |-------|----------| @@ -725,7 +725,7 @@ service_tier: priority ### `forensics_dedup` (v2.43) -Opt-in: search existing issues and PRs before filing from `/gsd forensics`. Uses additional AI tokens. +Opt-in: search existing issues and PRs before filing from `/sf forensics`. Uses additional AI tokens. ```yaml forensics_dedup: true # default: false @@ -826,7 +826,7 @@ notifications: auto_visualize: true # Service tier -service_tier: priority # "priority" or "flex" (for /gsd fast) +service_tier: priority # "priority" or "flex" (for /sf fast) # Diagnostics forensics_dedup: true # deduplicate before filing forensics issues diff --git a/docs/user-docs/cost-management.md b/docs/user-docs/cost-management.md index d835a12a5..2574f0d10 100644 --- a/docs/user-docs/cost-management.md +++ b/docs/user-docs/cost-management.md @@ -12,11 +12,11 @@ Every unit's metrics are captured automatically: - **Tool calls** — number of tool invocations - **Message counts** — assistant and user messages -Data is stored in `.gsd/metrics.json` and survives across sessions. +Data is stored in `.sf/metrics.json` and survives across sessions. ### Viewing Costs -**Dashboard:** `Ctrl+Alt+G` or `/gsd status` shows real-time cost breakdown. +**Dashboard:** `Ctrl+Alt+G` or `/sf status` shows real-time cost breakdown. **Aggregations available:** - By phase (research, planning, execution, completion, reassessment) @@ -85,9 +85,9 @@ See [Token Optimization](./token-optimization.md) for details. ## Tips - Start with `balanced` profile and a generous `budget_ceiling` to establish baseline costs -- Check `/gsd status` after a few slices to see per-slice cost averages +- Check `/sf status` after a few slices to see per-slice cost averages - Switch to `budget` profile for well-understood, repetitive work - Use `quality` only when architectural decisions are being made - Per-phase model selection lets you use Opus only for planning while keeping execution on Sonnet - Enable `dynamic_routing` for automatic model downgrading on simple tasks — see [Dynamic Model Routing](./dynamic-model-routing.md) -- Use `/gsd visualize` → Metrics tab to see where your budget is going +- Use `/sf visualize` → Metrics tab to see where your budget is going diff --git a/docs/user-docs/custom-models.md b/docs/user-docs/custom-models.md index 81012bb3d..48c0a96f0 100644 --- a/docs/user-docs/custom-models.md +++ b/docs/user-docs/custom-models.md @@ -1,6 +1,6 @@ # Custom Models -Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.gsd/agent/models.json`. +Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.sf/agent/models.json`. ## Table of Contents @@ -143,7 +143,7 @@ Shell operators (`;`, `|`, `&`, `` ` ``, `$`, `>`, `<`) are also blocked in comm **Customizing the allowlist:** -If you use a credential tool not on the default list, override it in global settings (`~/.gsd/agent/settings.json`): +If you use a credential tool not on the default list, override it in global settings (`~/.sf/agent/settings.json`): ```json { @@ -159,7 +159,7 @@ Alternatively, set the `SF_ALLOWED_COMMAND_PREFIXES` environment variable (comma export SF_ALLOWED_COMMAND_PREFIXES="pass,op,sops,doppler" ``` -> **Note:** This setting is global-only. Project-level settings.json (`/.gsd/settings.json`) cannot override the command allowlist — this prevents a cloned repo from escalating command execution privileges. +> **Note:** This setting is global-only. Project-level settings.json (`/.sf/settings.json`) cannot override the command allowlist — this prevents a cloned repo from escalating command execution privileges. ### Custom Headers diff --git a/docs/user-docs/dynamic-model-routing.md b/docs/user-docs/dynamic-model-routing.md index bc88df2bd..41799df23 100644 --- a/docs/user-docs/dynamic-model-routing.md +++ b/docs/user-docs/dynamic-model-routing.md @@ -258,7 +258,7 @@ For `execute-task` units, the classifier analyzes the task plan: ### Adaptive Learning -The routing history (`.gsd/routing-history.json`) tracks success/failure per tier per unit type. If a tier's failure rate exceeds 20% for a given pattern, future classifications are bumped up. User feedback (`over`/`under`/`ok`) is weighted 2× vs automatic outcomes. +The routing history (`.sf/routing-history.json`) tracks success/failure per tier per unit type. If a tier's failure rate exceeds 20% for a given pattern, future classifications are bumped up. User feedback (`over`/`under`/`ok`) is weighted 2× vs automatic outcomes. ## Interaction with Token Profiles diff --git a/docs/user-docs/getting-started.md b/docs/user-docs/getting-started.md index d5edc2300..abec860be 100644 --- a/docs/user-docs/getting-started.md +++ b/docs/user-docs/getting-started.md @@ -54,7 +54,7 @@ npm install -g sf-run export ANTHROPIC_API_KEY="sk-ant-..." # Option B: Use the built-in config wizard -gsd config +sf config ``` To persist the key, add the export line to `~/.zshrc`: @@ -70,24 +70,24 @@ See [Provider Setup Guide](./providers.md) for all 20+ supported providers. ```bash cd ~/my-project # navigate to any project -gsd # start a session +sf # start a session ``` **Step 7 — Verify everything works:** ```bash -gsd --version # prints the installed version +sf --version # prints the installed version ``` Inside the session, type `/model` to confirm your LLM is connected. -> **Apple Silicon PATH fix:** If `gsd` isn't found after install, npm's global bin may not be in your PATH: +> **Apple Silicon PATH fix:** If `sf` isn't found after install, npm's global bin may not be in your PATH: > ```bash > echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc > source ~/.zshrc > ``` -> **oh-my-zsh conflict:** The oh-my-zsh git plugin defines `alias gsd='git svn dcommit'`. Fix with `unalias gsd 2>/dev/null` in `~/.zshrc`, or use `gsd-cli` instead. +> **oh-my-zsh conflict:** The oh-my-zsh git plugin defines `alias sf='git svn dcommit'`. Fix with `unalias sf 2>/dev/null` in `~/.zshrc`, or use `sf-cli` instead. --- @@ -126,7 +126,7 @@ npm install -g sf-run $env:ANTHROPIC_API_KEY = "sk-ant-..." # Option B: Use the built-in config wizard -gsd config +sf config ``` To persist the key permanently, add it via System Settings > Environment Variables, or run: @@ -141,13 +141,13 @@ See [Provider Setup Guide](./providers.md) for all 20+ supported providers. ```powershell cd C:\Users\you\my-project # navigate to any project -gsd # start a session +sf # start a session ``` **Step 7 — Verify everything works:** ```powershell -gsd --version # prints the installed version +sf --version # prints the installed version ``` Inside the session, type `/model` to confirm your LLM is connected. @@ -160,7 +160,7 @@ Inside the session, type `/model` to confirm your LLM is connected. > **Windows tips:** > - Use **Windows Terminal** or **PowerShell** for the best experience. Command Prompt works but has limited color support. -> - If `gsd` isn't recognized, restart your terminal. Windows needs a fresh terminal to pick up new PATH entries. +> - If `sf` isn't recognized, restart your terminal. Windows needs a fresh terminal to pick up new PATH entries. > - **WSL2** also works — install WSL, then follow the Linux instructions inside your distro. --- @@ -230,7 +230,7 @@ npm install -g sf-run export ANTHROPIC_API_KEY="sk-ant-..." # Option B: Use the built-in config wizard -gsd config +sf config ``` To persist the key, add the export line to `~/.bashrc` (or `~/.zshrc`): @@ -246,13 +246,13 @@ See [Provider Setup Guide](./providers.md) for all 20+ supported providers. ```bash cd ~/my-project # navigate to any project -gsd # start a session +sf # start a session ``` **Step 6 — Verify everything works:** ```bash -gsd --version # prints the installed version +sf --version # prints the installed version ``` Inside the session, type `/model` to confirm your LLM is connected. @@ -280,21 +280,21 @@ Run SF in an isolated sandbox without installing Node.js on your host. ```bash git clone https://github.com/singularity-forge/sf-run.git -cd gsd-2/docker +cd sf-2/docker ``` **Step 3 — Create and enter a sandbox:** ```bash -docker sandbox create --template . --name gsd-sandbox -docker sandbox exec -it gsd-sandbox bash +docker sandbox create --template . --name sf-sandbox +docker sandbox exec -it sf-sandbox bash ``` **Step 4 — Set your API key and run SF:** ```bash export ANTHROPIC_API_KEY="sk-ant-..." -gsd auto "implement the feature described in issue #42" +sf auto "implement the feature described in issue #42" ``` See [Docker Sandbox docs](../../docker/README.md) for full configuration, resource limits, and compose files. @@ -317,23 +317,23 @@ Or configure per-phase models in preferences — see [Configuration](./configura ## Two Ways to Work -### Step Mode — `/gsd` +### Step Mode — `/sf` -Type `/gsd` inside a session. SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. +Type `/sf` inside a session. SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. -- **No `.gsd/` directory** — starts a discussion flow to capture your project vision +- **No `.sf/` directory** — starts a discussion flow to capture your project vision - **Milestone exists, no roadmap** — discuss or research the milestone - **Roadmap exists, slices pending** — plan the next slice or execute a task - **Mid-task** — resume where you left off Step mode keeps you in the loop, reviewing output between each step. -### Auto Mode — `/gsd auto` +### Auto Mode — `/sf auto` -Type `/gsd auto` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. +Type `/sf auto` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. ``` -/gsd auto +/sf auto ``` See [Auto Mode](./auto-mode.md) for full details. @@ -347,20 +347,20 @@ Run auto mode in one terminal, steer from another. **Terminal 1 — let it build:** ```bash -gsd -/gsd auto +sf +/sf auto ``` **Terminal 2 — steer while it works:** ```bash -gsd -/gsd discuss # talk through architecture decisions -/gsd status # check progress -/gsd queue # queue the next milestone +sf +/sf discuss # talk through architecture decisions +/sf status # check progress +/sf queue # queue the next milestone ``` -Both terminals read and write the same `.gsd/` files. Decisions in terminal 2 are picked up at the next phase boundary automatically. +Both terminals read and write the same `.sf/` files. Decisions in terminal 2 are picked up at the next phase boundary automatically. --- @@ -374,10 +374,10 @@ Milestone → a shippable version (4-10 slices) The iron rule: **a task must fit in one context window.** If it can't, it's two tasks. -All state lives on disk in `.gsd/`: +All state lives on disk in `.sf/`: ``` -.gsd/ +.sf/ PROJECT.md — what the project is right now REQUIREMENTS.md — requirement contract DECISIONS.md — append-only architectural decisions @@ -398,7 +398,7 @@ All state lives on disk in `.gsd/`: SF is also available as a VS Code extension. Install from the marketplace (publisher: FluxLabs) or search for "SF" in VS Code extensions: -- **`@gsd` chat participant** — talk to the agent in VS Code Chat +- **`@sf` chat participant** — talk to the agent in VS Code Chat - **Sidebar dashboard** — connection status, model info, token usage - **Full command palette** — start/stop agent, switch models, export sessions @@ -411,7 +411,7 @@ The CLI (`sf-run`) must be installed first — the extension connects to it via SF has a browser-based interface for visual project management: ```bash -gsd --web +sf --web ``` See [Web Interface](./web-interface.md) for details. @@ -421,7 +421,7 @@ See [Web Interface](./web-interface.md) for details. ## Resume a Session ```bash -gsd --continue # or gsd -c +sf --continue # or sf -c ``` Resumes the most recent session for the current directory. @@ -429,7 +429,7 @@ Resumes the most recent session for the current directory. Browse all saved sessions: ```bash -gsd sessions +sf sessions ``` --- @@ -445,7 +445,7 @@ npm update -g sf-run Or from within a session: ``` -/gsd update +/sf update ``` --- @@ -454,11 +454,11 @@ Or from within a session: | Problem | Fix | |---------|-----| -| `command not found: gsd` | Add npm global bin to PATH (see OS-specific notes above) | -| `gsd` runs `git svn dcommit` | oh-my-zsh conflict — `unalias gsd` or use `gsd-cli` | +| `command not found: sf` | Add npm global bin to PATH (see OS-specific notes above) | +| `sf` runs `git svn dcommit` | oh-my-zsh conflict — `unalias sf` or use `sf-cli` | | Permission errors on `npm install -g` | Fix npm prefix (see Linux notes) or use nvm | -| Can't connect to LLM | Check API key with `gsd config`, verify network access | -| `gsd` hangs on start | Check Node.js version: `node --version` (need 22+) | +| Can't connect to LLM | Check API key with `sf config`, verify network access | +| `sf` hangs on start | Check Node.js version: `node --version` (need 22+) | For more, see [Troubleshooting](./troubleshooting.md). diff --git a/docs/user-docs/git-strategy.md b/docs/user-docs/git-strategy.md index a1d0c075c..957dd1a42 100644 --- a/docs/user-docs/git-strategy.md +++ b/docs/user-docs/git-strategy.md @@ -8,13 +8,13 @@ SF supports three isolation modes, configured via the `git.isolation` preference | Mode | Working Directory | Branch | Best For | |------|-------------------|--------|----------| -| `worktree` (default) | `.gsd/worktrees//` | `milestone/` | Most projects — full file isolation between milestones | +| `worktree` (default) | `.sf/worktrees//` | `milestone/` | Most projects — full file isolation between milestones | | `branch` | Project root | `milestone/` | Submodule-heavy repos where worktrees don't work well | | `none` | Project root | Current branch (no milestone branch) | Hot-reload workflows where file isolation breaks dev tooling | ### `worktree` Mode (Default) -Each milestone gets its own git worktree at `.gsd/worktrees//` on a `milestone/` branch. All execution happens inside the worktree. On completion, the worktree is squash-merged to main as one clean commit. The worktree and branch are then cleaned up. +Each milestone gets its own git worktree at `.sf/worktrees//` on a `milestone/` branch. All execution happens inside the worktree. On completion, the worktree is squash-merged to main as one clean commit. The worktree and branch are then cleaned up. This provides full file isolation — changes in a milestone can't interfere with your main working copy. @@ -95,8 +95,8 @@ These features apply only in **worktree mode**. Auto mode creates and manages worktrees automatically: -1. When a milestone starts, a worktree is created at `.gsd/worktrees//` on branch `milestone/` -2. Planning artifacts from `.gsd/milestones/` are copied into the worktree +1. When a milestone starts, a worktree is created at `.sf/worktrees//` on branch `milestone/` +2. Planning artifacts from `.sf/milestones/` are copied into the worktree 3. All execution happens inside the worktree 4. On milestone completion, the worktree is squash-merged to the integration branch 5. The worktree and branch are removed @@ -148,7 +148,7 @@ git: pre_merge_check: false # pre-merge validation commit_type: feat # override commit type prefix main_branch: main # primary branch name - commit_docs: true # commit .gsd/ to git + commit_docs: true # commit .sf/ to git isolation: worktree # "worktree", "branch", or "none" auto_pr: false # create PR on milestone completion pr_target_branch: develop # PR target branch (default: main) @@ -170,7 +170,7 @@ This pushes the milestone branch and creates a PR targeting `develop` (or whiche ### `commit_docs: false` -When set to `false`, SF adds `.gsd/` to `.gitignore` and keeps all planning artifacts local-only. Useful for teams where only some members use SF, or when company policy requires a clean repository. +When set to `false`, SF adds `.sf/` to `.gitignore` and keeps all planning artifacts local-only. Useful for teams where only some members use SF, or when company policy requires a clean repository. ## Self-Healing @@ -180,7 +180,7 @@ SF includes automatic recovery for common git issues: - **Stale lock files** — removes `index.lock` files from crashed processes - **Orphaned worktrees** — detects and offers to clean up abandoned worktrees (worktree mode only) -Run `/gsd doctor` to check git health manually. +Run `/sf doctor` to check git health manually. ## Native Git Operations diff --git a/docs/user-docs/migration.md b/docs/user-docs/migration.md index 4652074f6..f99bb1444 100644 --- a/docs/user-docs/migration.md +++ b/docs/user-docs/migration.md @@ -1,15 +1,15 @@ # Migration from v1 -If you have projects with `.planning` directories from the original Singularity Forge (v1), you can migrate them to SF's `.gsd` format. +If you have projects with `.planning` directories from the original Singularity Forge (v1), you can migrate them to SF's `.sf` format. ## Running the Migration ```bash # From within the project directory -/gsd migrate +/sf migrate # Or specify a path -/gsd migrate ~/projects/my-old-project +/sf migrate ~/projects/my-old-project ``` ## What Gets Migrated @@ -42,7 +42,7 @@ Migration works best with a `ROADMAP.md` file for milestone structure. Without o After migrating, verify the output with: ``` -/gsd doctor +/sf doctor ``` -This checks `.gsd/` integrity and flags any structural issues. +This checks `.sf/` integrity and flags any structural issues. diff --git a/docs/user-docs/node-lts-macos.md b/docs/user-docs/node-lts-macos.md index 766d78f7c..04f285863 100644 --- a/docs/user-docs/node-lts-macos.md +++ b/docs/user-docs/node-lts-macos.md @@ -71,5 +71,5 @@ After pinning: ```bash node --version # v24.x.x npm install -g sf-run -gsd --version +sf --version ``` diff --git a/docs/user-docs/parallel-orchestration.md b/docs/user-docs/parallel-orchestration.md index a8ab66765..fd7c1dbe0 100644 --- a/docs/user-docs/parallel-orchestration.md +++ b/docs/user-docs/parallel-orchestration.md @@ -19,7 +19,7 @@ parallel: 2. Start parallel execution: ``` -/gsd parallel start +/sf parallel start ``` SF scans your milestones, checks dependencies and file overlap, shows an eligibility report, and spawns workers for eligible milestones. @@ -27,13 +27,13 @@ SF scans your milestones, checks dependencies and file overlap, shows an eligibi 3. Monitor progress: ``` -/gsd parallel status +/sf parallel status ``` 4. Stop when done: ``` -/gsd parallel stop +/sf parallel stop ``` ## How It Works @@ -58,7 +58,7 @@ SF scans your milestones, checks dependencies and file overlap, shows an eligibi │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ -│ .gsd/worktrees/ .gsd/worktrees/ .gsd/worktrees/ │ +│ .sf/worktrees/ .sf/worktrees/ .sf/worktrees/ │ │ M001/ M003/ M005/ │ │ (milestone/ (milestone/ (milestone/ │ │ M001 branch) M003 branch) M005 branch) │ @@ -67,7 +67,7 @@ SF scans your milestones, checks dependencies and file overlap, shows an eligibi ### Worker Isolation -Each worker is a separate `gsd` process with complete isolation: +Each worker is a separate `sf` process with complete isolation: | Resource | Isolation Method | |----------|-----------------| @@ -75,15 +75,15 @@ Each worker is a separate `gsd` process with complete isolation: | **Git branch** | `milestone/` — one branch per milestone | | **State derivation** | `SF_MILESTONE_LOCK` env var — `deriveState()` only sees the assigned milestone | | **Context window** | Separate process — each worker has its own agent sessions | -| **Metrics** | Each worktree has its own `.gsd/metrics.json` | -| **Crash recovery** | Each worktree has its own `.gsd/auto.lock` | +| **Metrics** | Each worktree has its own `.sf/metrics.json` | +| **Crash recovery** | Each worktree has its own `.sf/auto.lock` | ### Coordination Workers and the coordinator communicate through file-based IPC: -- **Session status files** (`.gsd/parallel/.status.json`) — workers write heartbeats, the coordinator reads them -- **Signal files** (`.gsd/parallel/.signal.json`) — coordinator writes signals, workers consume them +- **Session status files** (`.sf/parallel/.status.json`) — workers write heartbeats, the coordinator reads them +- **Signal files** (`.sf/parallel/.signal.json`) — coordinator writes signals, workers consume them - **Atomic writes** — write-to-temp + rename prevents partial reads ## Eligibility Analysis @@ -126,7 +126,7 @@ File overlaps are warnings, not blockers. Both milestones work in separate workt ## Configuration -Add to `~/.gsd/PREFERENCES.md` or `.gsd/PREFERENCES.md`: +Add to `~/.sf/PREFERENCES.md` or `.sf/PREFERENCES.md`: ```yaml --- @@ -143,26 +143,26 @@ parallel: | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `false` | Master toggle. Must be `true` for `/gsd parallel` commands to work. | +| `enabled` | boolean | `false` | Master toggle. Must be `true` for `/sf parallel` commands to work. | | `max_workers` | number (1-4) | `2` | Maximum concurrent worker processes. Higher values use more memory and API budget. | | `budget_ceiling` | number | none | Aggregate cost ceiling in USD across all workers. When reached, no new units are dispatched. | | `merge_strategy` | `"per-slice"` or `"per-milestone"` | `"per-milestone"` | When worktree changes merge back to main. Per-milestone waits for the full milestone to complete. | -| `auto_merge` | `"auto"`, `"confirm"`, `"manual"` | `"confirm"` | How merge-back is handled. `confirm` prompts before merging. `manual` requires explicit `/gsd parallel merge`. | +| `auto_merge` | `"auto"`, `"confirm"`, `"manual"` | `"confirm"` | How merge-back is handled. `confirm` prompts before merging. `manual` requires explicit `/sf parallel merge`. | ## Commands | Command | Description | |---------|-------------| -| `/gsd parallel start` | Analyze eligibility, confirm, and start workers | -| `/gsd parallel status` | Show all workers with state, units completed, and cost | -| `/gsd parallel stop` | Stop all workers (sends SIGTERM) | -| `/gsd parallel stop M002` | Stop a specific milestone's worker | -| `/gsd parallel pause` | Pause all workers (finish current unit, then wait) | -| `/gsd parallel pause M002` | Pause a specific worker | -| `/gsd parallel resume` | Resume all paused workers | -| `/gsd parallel resume M002` | Resume a specific worker | -| `/gsd parallel merge` | Merge all completed milestones back to main | -| `/gsd parallel merge M002` | Merge a specific milestone back to main | +| `/sf parallel start` | Analyze eligibility, confirm, and start workers | +| `/sf parallel status` | Show all workers with state, units completed, and cost | +| `/sf parallel stop` | Stop all workers (sends SIGTERM) | +| `/sf parallel stop M002` | Stop a specific milestone's worker | +| `/sf parallel pause` | Pause all workers (finish current unit, then wait) | +| `/sf parallel pause M002` | Pause a specific worker | +| `/sf parallel resume` | Resume all paused workers | +| `/sf parallel resume M002` | Resume a specific worker | +| `/sf parallel merge` | Merge all completed milestones back to main | +| `/sf parallel merge M002` | Merge a specific milestone back to main | ## Signal Lifecycle @@ -200,13 +200,13 @@ When milestones complete, their worktree changes need to merge back to main. ### Conflict Handling -1. `.gsd/` state files (STATE.md, metrics.json, etc.) — **auto-resolved** by accepting the milestone branch version -2. Code conflicts — **stop and report**. The merge halts, showing which files conflict. Resolve manually and retry with `/gsd parallel merge `. +1. `.sf/` state files (STATE.md, metrics.json, etc.) — **auto-resolved** by accepting the milestone branch version +2. Code conflicts — **stop and report**. The merge halts, showing which files conflict. Resolve manually and retry with `/sf parallel merge `. ### Example ``` -/gsd parallel merge +/sf parallel merge # Merge Results @@ -214,7 +214,7 @@ When milestones complete, their worktree changes need to merge back to main. - **M003** — CONFLICT (2 file(s)): - `src/types.ts` - `src/middleware.ts` - Resolve conflicts manually and run `/gsd parallel merge M003` to retry. + Resolve conflicts manually and run `/sf parallel merge M003` to retry. ``` ## Budget Management @@ -229,11 +229,11 @@ When `budget_ceiling` is set, the coordinator tracks aggregate cost across all w ### Doctor Integration -`/gsd doctor` detects parallel session issues: +`/sf doctor` detects parallel session issues: -- **Stale parallel sessions** — Worker process died without cleanup. Doctor finds `.gsd/parallel/*.status.json` files with dead PIDs or expired heartbeats and removes them. +- **Stale parallel sessions** — Worker process died without cleanup. Doctor finds `.sf/parallel/*.status.json` files with dead PIDs or expired heartbeats and removes them. -Run `/gsd doctor --fix` to clean up automatically. +Run `/sf doctor --fix` to clean up automatically. ### Stale Detection @@ -255,12 +255,12 @@ The coordinator runs stale detection during `refreshWorkerStatuses()` and automa | **Budget ceiling** | Aggregate cost enforcement across all workers | | **Signal-based shutdown** | Graceful stop via file signals + SIGTERM | | **Doctor integration** | Detects and cleans up orphaned sessions | -| **Conflict-aware merge** | Stops on code conflicts, auto-resolves `.gsd/` state conflicts | +| **Conflict-aware merge** | Stops on code conflicts, auto-resolves `.sf/` state conflicts | ## File Layout ``` -.gsd/ +.sf/ ├── parallel/ # Coordinator ↔ worker IPC │ ├── M002.status.json # Worker heartbeat + progress │ ├── M002.signal.json # Coordinator → worker signals @@ -268,7 +268,7 @@ The coordinator runs stale detection during `refreshWorkerStatuses()` and automa │ └── M003.signal.json ├── worktrees/ # Git worktrees (one per milestone) │ ├── M002/ # M002's isolated checkout -│ │ ├── .gsd/ # M002's own state files +│ │ ├── .sf/ # M002's own state files │ │ │ ├── auto.lock │ │ │ ├── metrics.json │ │ │ └── milestones/ @@ -278,7 +278,7 @@ The coordinator runs stale detection during `refreshWorkerStatuses()` and automa └── ... ``` -Both `.gsd/parallel/` and `.gsd/worktrees/` are gitignored — they're runtime-only coordination files that never get committed. +Both `.sf/parallel/` and `.sf/worktrees/` are gitignored — they're runtime-only coordination files that never get committed. ## Troubleshooting @@ -288,22 +288,22 @@ Set `parallel.enabled: true` in your preferences file. ### "No milestones are eligible for parallel execution" -All milestones are either complete or blocked by dependencies. Check `/gsd queue` to see milestone status and dependency chains. +All milestones are either complete or blocked by dependencies. Check `/sf queue` to see milestone status and dependency chains. ### Worker crashed — how to recover Workers now persist their state to disk automatically. If a worker process dies, the coordinator detects the dead PID via heartbeat expiry and marks the worker as crashed. On restart, the worker picks up from disk state — crash recovery, worktree re-entry, and completed-unit tracking carry over from the crashed session. -1. Run `/gsd doctor --fix` to clean up stale sessions -2. Run `/gsd parallel status` to see current state -3. Re-run `/gsd parallel start` to spawn new workers for remaining milestones +1. Run `/sf doctor --fix` to clean up stale sessions +2. Run `/sf parallel status` to see current state +3. Re-run `/sf parallel start` to spawn new workers for remaining milestones ### Merge conflicts after parallel completion -1. Run `/gsd parallel merge` to see which milestones have conflicts -2. Resolve conflicts in the worktree at `.gsd/worktrees//` -3. Retry with `/gsd parallel merge ` +1. Run `/sf parallel merge` to see which milestones have conflicts +2. Resolve conflicts in the worktree at `.sf/worktrees//` +3. Retry with `/sf parallel merge ` ### Workers seem stuck -Check if budget ceiling was reached: `/gsd parallel status` shows per-worker costs. Increase `parallel.budget_ceiling` or remove it to continue. +Check if budget ceiling was reached: `/sf parallel status` shows per-worker costs. Increase `parallel.budget_ceiling` or remove it to continue. diff --git a/docs/user-docs/providers.md b/docs/user-docs/providers.md index 819773b12..dcb175e3d 100644 --- a/docs/user-docs/providers.md +++ b/docs/user-docs/providers.md @@ -1,6 +1,6 @@ # Provider Setup Guide -Step-by-step setup instructions for every LLM provider SF supports. If you ran the onboarding wizard (`gsd config`) and picked a provider, you may already be configured — check with `/model` inside a session. +Step-by-step setup instructions for every LLM provider SF supports. If you ran the onboarding wizard (`sf config`) and picked a provider, you may already be configured — check with `/model` inside a session. ## Table of Contents @@ -61,7 +61,7 @@ Built-in providers have models pre-registered in SF. You only need to supply cre export ANTHROPIC_API_KEY="sk-ant-..." ``` -Or run `gsd config` and paste your key when prompted. +Or run `sf config` and paste your key when prompted. **Get a key:** [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) @@ -73,7 +73,7 @@ If you have a Claude Pro or Max subscription, you can authenticate through Anthr # Install Claude Code CLI (see https://docs.anthropic.com/en/docs/claude-code) claude # Sign in when prompted, then start SF -gsd +sf ``` SF detects your local Claude Code installation and uses it as the authenticated Anthropic surface. This is the TOS-compliant path for subscription users — SF never handles your subscription credentials directly. @@ -91,10 +91,10 @@ When SF detects a Claude Code model during startup, it automatically writes a `. You can also trigger this manually from inside a SF session: ```bash -/gsd mcp init +/sf mcp init ``` -This writes (or updates) the `gsd-workflow` entry in your project's `.mcp.json`. Claude Code discovers this file automatically on its next session start. +This writes (or updates) the `sf-workflow` entry in your project's `.mcp.json`. Claude Code discovers this file automatically on its next session start. **Manual setup:** @@ -103,24 +103,24 @@ If you prefer to configure it yourself, add SF to your project's `.mcp.json`: ```json { "mcpServers": { - "gsd": { + "sf": { "command": "npx", - "args": ["gsd-mcp-server"], + "args": ["sf-mcp-server"], "env": { - "SF_CLI_PATH": "/path/to/gsd" + "SF_CLI_PATH": "/path/to/sf" } } } } ``` -Or if `gsd-mcp-server` is installed globally: +Or if `sf-mcp-server` is installed globally: ```json { "mcpServers": { - "gsd": { - "command": "gsd-mcp-server" + "sf": { + "command": "sf-mcp-server" } } } @@ -137,7 +137,7 @@ The MCP server provides SF's full workflow tool surface — milestone planning, From inside a SF session, check that the MCP server is reachable: ```bash -/gsd mcp status +/sf mcp status ``` ### OpenAI @@ -146,7 +146,7 @@ From inside a SF session, check that the MCP server is reachable: export OPENAI_API_KEY="sk-..." ``` -Or run `gsd config` and choose "Paste an API key" then "OpenAI". +Or run `sf config` and choose "Paste an API key" then "OpenAI". **Get a key:** [platform.openai.com/api-keys](https://platform.openai.com/api-keys) @@ -172,7 +172,7 @@ Go to [openrouter.ai/keys](https://openrouter.ai/keys) and create a key. export OPENROUTER_API_KEY="sk-or-..." ``` -Or run `gsd config`, choose "Paste an API key", then "OpenRouter". +Or run `sf config`, choose "Paste an API key", then "OpenRouter". **Step 3 — Switch to an OpenRouter model:** @@ -180,7 +180,7 @@ Inside a SF session, type `/model` and select an OpenRouter model. Models are pr **Optional — Add custom OpenRouter models via `models.json`:** -If you want models not in the built-in list, add them to `~/.gsd/agent/models.json`: +If you want models not in the built-in list, add them to `~/.sf/agent/models.json`: ```json { @@ -258,7 +258,7 @@ export MISTRAL_API_KEY="..." Uses OAuth — sign in through the browser: ```bash -gsd config +sf config # Choose "Sign in with your browser" → "GitHub Copilot" ``` @@ -306,7 +306,7 @@ export AZURE_OPENAI_API_KEY="..." Local providers run on your machine. They require a `models.json` configuration file because SF needs to know the endpoint URL and which models are available. -**Config file location:** `~/.gsd/agent/models.json` +**Config file location:** `~/.sf/agent/models.json` The file reloads each time you open `/model` — no restart needed. @@ -329,7 +329,7 @@ ollama pull llama3.1:8b ollama pull qwen2.5-coder:7b ``` -**Step 3 — Create `~/.gsd/agent/models.json`:** +**Step 3 — Create `~/.sf/agent/models.json`:** ```json { @@ -372,7 +372,7 @@ Download from [lmstudio.ai](https://lmstudio.ai). In LM Studio, go to the "Local Server" tab, load a model, and click "Start Server". The default port is 1234. -**Step 3 — Create `~/.gsd/agent/models.json`:** +**Step 3 — Create `~/.sf/agent/models.json`:** ```json { @@ -465,12 +465,12 @@ Any server that implements the OpenAI Chat Completions API can work with SF. Thi **Quickest path — use the onboarding wizard:** ```bash -gsd config +sf config # Choose "Paste an API key" → "Custom (OpenAI-compatible)" # Enter: base URL, API key, model ID ``` -This writes `~/.gsd/agent/models.json` for you automatically. +This writes `~/.sf/agent/models.json` for you automatically. **Manual setup:** @@ -540,7 +540,7 @@ For the full reference on `compat` fields, `modelOverrides`, value resolution, a **Cause:** The key is set in your shell but not visible to SF. -**Fix:** Make sure the environment variable is exported in the same terminal where you run `gsd`. Or use `gsd config` to save the key to `~/.gsd/agent/auth.json` so it persists across sessions. +**Fix:** Make sure the environment variable is exported in the same terminal where you run `sf`. Or use `sf config` to save the key to `~/.sf/agent/auth.json` so it persists across sessions. ### OpenRouter models not appearing in `/model` @@ -550,7 +550,7 @@ For the full reference on `compat` fields, `modelOverrides`, value resolution, a ```bash export OPENROUTER_API_KEY="sk-or-..." -gsd +sf ``` ### Ollama returns empty responses @@ -630,7 +630,7 @@ After configuring a provider: 1. **Launch SF:** ```bash - gsd + sf ``` 2. **Check available models:** @@ -647,7 +647,7 @@ After configuring a provider: If the model doesn't appear, check: - The environment variable is set in the current shell -- `models.json` is valid JSON (use `cat ~/.gsd/agent/models.json | python3 -m json.tool`) +- `models.json` is valid JSON (use `cat ~/.sf/agent/models.json | python3 -m json.tool`) - The server is running (for local providers) -For additional help, see [Troubleshooting](./troubleshooting.md) or run `/gsd doctor` inside a session. +For additional help, see [Troubleshooting](./troubleshooting.md) or run `/sf doctor` inside a session. diff --git a/docs/user-docs/remote-questions.md b/docs/user-docs/remote-questions.md index 4ca2c29d4..91eace8e2 100644 --- a/docs/user-docs/remote-questions.md +++ b/docs/user-docs/remote-questions.md @@ -7,7 +7,7 @@ Remote questions allow SF to ask for user input via Slack, Discord, or Telegram ### Discord ``` -/gsd remote discord +/sf remote discord ``` The setup wizard: @@ -16,7 +16,7 @@ The setup wizard: 3. Lists servers the bot belongs to (or lets you pick) 4. Lists text channels in the selected server 5. Sends a test message to confirm permissions -6. Saves the configuration to `~/.gsd/PREFERENCES.md` +6. Saves the configuration to `~/.sf/PREFERENCES.md` **Bot requirements:** - A Discord bot application with a token (from [Discord Developer Portal](https://discord.com/developers/applications)) @@ -30,7 +30,7 @@ The setup wizard: ### Slack ``` -/gsd remote slack +/sf remote slack ``` The setup wizard: @@ -48,7 +48,7 @@ The setup wizard: ### Telegram ``` -/gsd remote telegram +/sf remote telegram ``` The setup wizard: @@ -65,7 +65,7 @@ The setup wizard: ## Configuration -Remote questions are configured in `~/.gsd/PREFERENCES.md`: +Remote questions are configured in `~/.sf/PREFERENCES.md`: ```yaml remote_questions: @@ -105,11 +105,11 @@ If no response is received within `timeout_minutes`, the prompt times out and SF | Command | Description | |---------|-------------| -| `/gsd remote` | Show remote questions menu and current status | -| `/gsd remote slack` | Set up Slack integration | -| `/gsd remote discord` | Set up Discord integration | -| `/gsd remote status` | Show current configuration and last prompt status | -| `/gsd remote disconnect` | Remove remote questions configuration | +| `/sf remote` | Show remote questions menu and current status | +| `/sf remote slack` | Set up Slack integration | +| `/sf remote discord` | Set up Discord integration | +| `/sf remote status` | Show current configuration and last prompt status | +| `/sf remote disconnect` | Remove remote questions configuration | ## Discord vs Slack Feature Comparison diff --git a/docs/user-docs/skills.md b/docs/user-docs/skills.md index 7d2c4e8b5..d28cc6015 100644 --- a/docs/user-docs/skills.md +++ b/docs/user-docs/skills.md @@ -15,7 +15,7 @@ SF reads skills from two locations, in priority order: Global skills take precedence over project skills when names collide. -> **Migration from `~/.gsd/agent/skills/`:** On first launch after upgrading, SF automatically copies skills from the legacy `~/.gsd/agent/skills/` directory to `~/.agents/skills/`. The old directory is preserved for backward compatibility. +> **Migration from `~/.sf/agent/skills/`:** On first launch after upgrading, SF automatically copies skills from the legacy `~/.sf/agent/skills/` directory to `~/.agents/skills/`. The old directory is preserved for backward compatibility. ## Installing Skills @@ -40,9 +40,9 @@ npx skills update ### Onboarding Catalog -During `gsd init`, SF detects the project's tech stack and recommends relevant skill packs. For brownfield projects, detection is automatic; for greenfield projects, the user picks a tech stack. +During `sf init`, SF detects the project's tech stack and recommends relevant skill packs. For brownfield projects, detection is automatic; for greenfield projects, the user picks a tech stack. -The curated catalog is maintained in `src/resources/extensions/gsd/skill-catalog.ts`. Each entry maps a tech stack to a skills.sh repo and specific skill names. +The curated catalog is maintained in `src/resources/extensions/sf/skill-catalog.ts`. Each entry maps a tech stack to a skills.sh repo and specific skill names. #### Available Skill Packs @@ -73,7 +73,7 @@ The curated catalog is maintained in `src/resources/extensions/gsd/skill-catalog ### Maintaining the Catalog -The skill catalog lives in [`src/resources/extensions/gsd/skill-catalog.ts`](../src/resources/extensions/gsd/skill-catalog.ts). To add or update a pack: +The skill catalog lives in [`src/resources/extensions/sf/skill-catalog.ts`](../src/resources/extensions/sf/skill-catalog.ts). To add or update a pack: 1. Add a `SkillPack` entry to the `SKILL_CATALOG` array with `repo`, `skills`, and matching criteria 2. For language-detection matching, use `matchLanguages` (values from `detection.ts` `LANGUAGE_MAP`) @@ -155,13 +155,13 @@ Every auto-mode unit records which skills were available and actively loaded. Th ### Skill Health Dashboard -View skill performance with `/gsd skill-health`: +View skill performance with `/sf skill-health`: ``` -/gsd skill-health # overview table: name, uses, success%, tokens, trend, last used -/gsd skill-health rust-core # detailed view for one skill -/gsd skill-health --stale 30 # skills unused for 30+ days -/gsd skill-health --declining # skills with falling success rates +/sf skill-health # overview table: name, uses, success%, tokens, trend, last used +/sf skill-health rust-core # detailed view for one skill +/sf skill-health --stale 30 # skills unused for 30+ days +/sf skill-health --declining # skills with falling success rates ``` The dashboard flags skills that may need attention: @@ -183,6 +183,6 @@ Stale skills are excluded from automatic matching but remain invokable explicitl ### Heal-Skill (Post-Unit Analysis) -When configured as a post-unit hook, SF can analyze whether the agent deviated from a skill's instructions during execution. If significant drift is detected (outdated API patterns, incorrect guidance), it writes proposed fixes to `.gsd/skill-review-queue.md` for human review. +When configured as a post-unit hook, SF can analyze whether the agent deviated from a skill's instructions during execution. If significant drift is detected (outdated API patterns, incorrect guidance), it writes proposed fixes to `.sf/skill-review-queue.md` for human review. Key design principle: skills are **never auto-modified**. Research shows curated skills outperform auto-generated ones significantly, so the human review step is critical. diff --git a/docs/user-docs/token-optimization.md b/docs/user-docs/token-optimization.md index d4fcc36e9..d60f34133 100644 --- a/docs/user-docs/token-optimization.md +++ b/docs/user-docs/token-optimization.md @@ -165,7 +165,7 @@ This graduated approach preserves model quality for the most complex work while ## Adaptive Learning (Routing History) -SF tracks the success and failure of each tier assignment over time and adjusts future classifications accordingly. This is opt-in — it happens automatically and persists in `.gsd/routing-history.json`. +SF tracks the success and failure of each tier assignment over time and adjusts future classifications accordingly. This is opt-in — it happens automatically and persists in `.sf/routing-history.json`. ### How It Works @@ -176,12 +176,12 @@ SF tracks the success and failure of each tier assignment over time and adjusts ### User Feedback -Use `/gsd rate` to submit feedback on the last completed unit's model tier: +Use `/sf rate` to submit feedback on the last completed unit's model tier: ``` -/gsd rate over # model was overpowered — encourage cheaper next time -/gsd rate ok # model was appropriate — no adjustment -/gsd rate under # model was too weak — encourage stronger next time +/sf rate over # model was overpowered — encourage cheaper next time +/sf rate ok # model was appropriate — no adjustment +/sf rate under # model was too weak — encourage stronger next time ``` Feedback signals are weighted 2× compared to automatic outcomes. Requires dynamic routing to be active (the last unit must have tier data). @@ -190,7 +190,7 @@ Feedback signals are weighted 2× compared to automatic outcomes. Requires dynam ```bash # Routing history is stored per-project -.gsd/routing-history.json +.sf/routing-history.json # Clear history to reset adaptive learning # (happens via the routing-history module API) @@ -309,7 +309,7 @@ Individual tool results that exceed `tool_result_max_chars` (default: 800) are t *Introduced in v2.59.0* -When auto-mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.gsd/milestones//anchors/.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files. +When auto-mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.sf/milestones//anchors/.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files. This reduces context drift — the 65% of enterprise agent failures caused by agents losing track of prior decisions across phase boundaries. diff --git a/docs/user-docs/troubleshooting.md b/docs/user-docs/troubleshooting.md index 264268946..b36fd86ec 100644 --- a/docs/user-docs/troubleshooting.md +++ b/docs/user-docs/troubleshooting.md @@ -1,11 +1,11 @@ # Troubleshooting -## `/gsd doctor` +## `/sf doctor` -The built-in diagnostic tool validates `.gsd/` integrity: +The built-in diagnostic tool validates `.sf/` integrity: ``` -/gsd doctor +/sf doctor ``` It checks: @@ -25,13 +25,13 @@ It checks: - Stale cache after a crash — the in-memory file listing doesn't reflect new artifacts - The LLM didn't produce the expected artifact file -**Fix:** Run `/gsd doctor` to repair state, then resume with `/gsd auto`. If the issue persists, check that the expected artifact file exists on disk. +**Fix:** Run `/sf doctor` to repair state, then resume with `/sf auto`. If the issue persists, check that the expected artifact file exists on disk. ### Auto mode stops with "Loop detected" **Cause:** A unit failed to produce its expected artifact twice in a row. -**Fix:** Check the task plan for clarity. If the plan is ambiguous, refine it manually, then `/gsd auto` to resume. +**Fix:** Check the task plan for clarity. If the plan is ambiguous, refine it manually, then `/sf auto` to resume. ### Wrong files in worktree @@ -41,9 +41,9 @@ It checks: **Fix:** This was fixed in v2.14+. If you're on an older version, update. The dispatch prompt now includes explicit working directory instructions. -### `command not found: gsd` after install +### `command not found: sf` after install -**Symptoms:** `npm install -g sf-run` succeeds but `gsd` isn't found. +**Symptoms:** `npm install -g sf-run` succeeds but `sf` isn't found. **Cause:** npm's global bin directory isn't in your shell's `$PATH`. @@ -59,12 +59,12 @@ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc source ~/.zshrc ``` -**Workaround:** Run `npx sf-run` or `$(npm prefix -g)/bin/gsd` directly. +**Workaround:** Run `npx sf-run` or `$(npm prefix -g)/bin/sf` directly. **Common causes:** - **Homebrew Node** — `/opt/homebrew/bin` should be in PATH but sometimes isn't if Homebrew init is missing from your shell profile - **Version manager (nvm, fnm, mise)** — global bin is version-specific; ensure your version manager initializes in your shell config -- **oh-my-zsh** — the `gitfast` plugin aliases `gsd` to `git svn dcommit`. Check with `alias gsd` and unalias if needed +- **oh-my-zsh** — the `gitfast` plugin aliases `sf` to `git svn dcommit`. Check with `alias sf` and unalias if needed ### `npm install -g sf-run` fails @@ -95,7 +95,7 @@ models: - openrouter/minimax/minimax-m2.5 ``` -**Headless mode:** `gsd headless auto` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution. +**Headless mode:** `sf headless auto` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution. For common provider setup issues (role errors, streaming errors, model ID mismatches), see the [Provider Setup Guide — Common Pitfalls](./providers.md#common-pitfalls). @@ -103,46 +103,46 @@ For common provider setup issues (role errors, streaming errors, model ID mismat **Symptoms:** Auto mode pauses with "Budget ceiling reached." -**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile to reduce per-unit cost, then resume with `/gsd auto`. +**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile to reduce per-unit cost, then resume with `/sf auto`. ### Stale lock file **Symptoms:** Auto mode won't start, says another session is running. -**Fix:** SF automatically detects stale locks — if the owning PID is dead, the lock is cleaned up and re-acquired on the next `/gsd auto`. This includes stranded `.gsd.lock/` directories left by `proper-lockfile` after crashes. If automatic recovery fails, delete `.gsd/auto.lock` and the `.gsd.lock/` directory manually: +**Fix:** SF automatically detects stale locks — if the owning PID is dead, the lock is cleaned up and re-acquired on the next `/sf auto`. This includes stranded `.sf.lock/` directories left by `proper-lockfile` after crashes. If automatic recovery fails, delete `.sf/auto.lock` and the `.sf.lock/` directory manually: ```bash -rm -f .gsd/auto.lock -rm -rf "$(dirname .gsd)/.gsd.lock" +rm -f .sf/auto.lock +rm -rf "$(dirname .sf)/.sf.lock" ``` ### Git merge conflicts -**Symptoms:** Worktree merge fails on `.gsd/` files. +**Symptoms:** Worktree merge fails on `.sf/` files. -**Fix:** SF auto-resolves conflicts on `.gsd/` runtime files. For content conflicts in code files, the LLM is given an opportunity to resolve them via a fix-merge session. If that fails, manual resolution is needed. +**Fix:** SF auto-resolves conflicts on `.sf/` runtime files. For content conflicts in code files, the LLM is given an opportunity to resolve them via a fix-merge session. If that fails, manual resolution is needed. ### Pre-dispatch says the milestone integration branch no longer exists -**Symptoms:** Auto mode or `/gsd doctor` reports that a milestone recorded an integration branch that no longer exists in git. +**Symptoms:** Auto mode or `/sf doctor` reports that a milestone recorded an integration branch that no longer exists in git. -**What it means:** The milestone's `.gsd/milestones//-META.json` still points at the branch that was active when the milestone started, but that branch has since been renamed or deleted. +**What it means:** The milestone's `.sf/milestones//-META.json` still points at the branch that was active when the milestone started, but that branch has since been renamed or deleted. **Current behavior:** - If SF can deterministically recover to a safe branch, it no longer hard-stops auto mode. - Safe fallbacks are: - explicit `git.main_branch` when configured and present - the repo's detected default integration branch (for example `main` or `master`) -- In that case `/gsd doctor` reports a warning and `/gsd doctor fix` rewrites the stale metadata to the effective branch. +- In that case `/sf doctor` reports a warning and `/sf doctor fix` rewrites the stale metadata to the effective branch. - SF still blocks when no safe fallback branch can be determined. **Fix:** -- Run `/gsd doctor fix` to rewrite the stale milestone metadata automatically when the fallback is obvious. +- Run `/sf doctor fix` to rewrite the stale milestone metadata automatically when the fallback is obvious. - If SF still blocks, recreate the missing branch or update your git preferences so `git.main_branch` points at a real branch. -### Transient `EBUSY` / `EPERM` / `EACCES` while writing `.gsd/` files +### Transient `EBUSY` / `EPERM` / `EACCES` while writing `.sf/` files -**Symptoms:** On Windows, auto mode or doctor occasionally fails while updating `.gsd/` files with errors like `EBUSY`, `EPERM`, or `EACCES`. +**Symptoms:** On Windows, auto mode or doctor occasionally fails while updating `.sf/` files with errors like `EBUSY`, `EPERM`, or `EACCES`. **Cause:** Antivirus, indexers, editors, or filesystem watchers can briefly lock the destination or temp file just as SF performs the atomic rename. @@ -151,11 +151,11 @@ rm -rf "$(dirname .gsd)/.gsd.lock" **Fix:** - Re-run the operation; most transient lock races clear quickly. - If the error persists, close tools that may be holding the file open and then retry. -- If repeated failures continue, run `/gsd doctor` to confirm the repo state is still healthy and report the exact path + error code. +- If repeated failures continue, run `/sf doctor` to confirm the repo state is still healthy and report the exact path + error code. ### Node v24 web boot failure -**Symptoms:** `gsd --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v24. +**Symptoms:** `sf --web` fails with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` on Node v24. **Cause:** Node v24 changed type-stripping behavior for `node_modules`, breaking the Next.js web build. @@ -163,7 +163,7 @@ rm -rf "$(dirname .gsd)/.gsd.lock" ### Orphan web server process -**Symptoms:** `gsd --web` fails because port 3000 is already in use, even though no SF session is running. +**Symptoms:** `sf --web` fails because port 3000 is already in use, even though no SF session is running. **Cause:** A previous web server process was not cleaned up on exit. @@ -192,12 +192,12 @@ rm -rf "$(dirname .gsd)/.gsd.lock" **Symptoms:** `mcp_servers` reports no servers configured. **Common causes:** -- No `.mcp.json` or `.gsd/mcp.json` file exists in the current project +- No `.mcp.json` or `.sf/mcp.json` file exists in the current project - The config file is malformed JSON - The server is configured in a different project directory than the one where you launched SF **Fix:** -- Add the server to `.mcp.json` or `.gsd/mcp.json` +- Add the server to `.mcp.json` or `.sf/mcp.json` - Verify the file parses as JSON - Re-run `mcp_servers(refresh=true)` @@ -258,11 +258,11 @@ rm -rf "$(dirname .gsd)/.gsd.lock" - Set required environment variables in the MCP config's `env` block - If needed, set `cwd` explicitly in the server definition -### Session lock stolen by `/gsd` in another terminal +### Session lock stolen by `/sf` in another terminal -**Symptoms:** Running `/gsd` (step mode) in a second terminal causes a running auto-mode session to lose its lock. +**Symptoms:** Running `/sf` (step mode) in a second terminal causes a running auto-mode session to lose its lock. -**Fix:** Fixed in v2.36.0. Bare `/gsd` no longer steals the session lock from a running auto-mode session. Upgrade to the latest version. +**Fix:** Fixed in v2.36.0. Bare `/sf` no longer steals the session lock from a running auto-mode session. Upgrade to the latest version. ### Worktree commits landing on main instead of milestone branch @@ -283,34 +283,34 @@ rm -rf "$(dirname .gsd)/.gsd.lock" ### Reset auto mode state ```bash -rm .gsd/auto.lock -rm .gsd/completed-units.json +rm .sf/auto.lock +rm .sf/completed-units.json ``` -Then `/gsd auto` to restart from current disk state. +Then `/sf auto` to restart from current disk state. ### Reset routing history If adaptive model routing is producing bad results, clear the routing history: ```bash -rm .gsd/routing-history.json +rm .sf/routing-history.json ``` ### Full state rebuild ``` -/gsd doctor +/sf doctor ``` Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detected inconsistencies. ## Getting Help -- **GitHub Issues:** [github.com/gsd-build/SF/issues](https://github.com/gsd-build/SF/issues) -- **Dashboard:** `Ctrl+Alt+G` or `/gsd status` for real-time diagnostics -- **Forensics:** `/gsd forensics` for structured post-mortem analysis of auto-mode failures -- **Session logs:** `.gsd/activity/` contains JSONL session dumps for crash forensics +- **GitHub Issues:** [github.com/sf-build/SF/issues](https://github.com/sf-build/SF/issues) +- **Dashboard:** `Ctrl+Alt+G` or `/sf status` for real-time diagnostics +- **Forensics:** `/sf forensics` for structured post-mortem analysis of auto-mode failures +- **Session logs:** `.sf/activity/` contains JSONL session dumps for crash forensics ## iTerm2-Specific Issues @@ -346,7 +346,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte **Symptoms:** `gsd_decision_save` (or its alias `gsd_save_decision`), `gsd_requirement_update` (or `gsd_update_requirement`), or `gsd_summary_save` (or `gsd_save_summary`) fail with this error. -**Cause:** The SQLite database wasn't initialized. This happens in manual `/gsd` sessions (non-auto mode) on versions before v2.29. +**Cause:** The SQLite database wasn't initialized. This happens in manual `/sf` sessions (non-auto mode) on versions before v2.29. **Fix:** Updated in v2.29+ to auto-initialize the database on first tool call. Upgrade to the latest version. diff --git a/docs/user-docs/visualizer.md b/docs/user-docs/visualizer.md index 2ca3e4159..1696bd3fe 100644 --- a/docs/user-docs/visualizer.md +++ b/docs/user-docs/visualizer.md @@ -7,7 +7,7 @@ The workflow visualizer is a full-screen TUI overlay that shows project progress ## Opening the Visualizer ``` -/gsd visualize +/sf visualize ``` Or configure automatic display after milestone completion: @@ -59,7 +59,7 @@ Bar charts showing cost and token usage breakdowns: - **By slice** — cost per slice with running totals - **By model** — which models consumed the most budget -Uses data from `.gsd/metrics.json`. +Uses data from `.sf/metrics.json`. ### 4. Timeline @@ -89,7 +89,7 @@ The visualizer refreshes data from disk every 2 seconds, so it stays current if ## HTML Export (v2.26) -For shareable reports outside the terminal, use `/gsd export --html`. This generates a self-contained HTML file in `.gsd/reports/` with the same data as the TUI visualizer — progress tree, dependency graph (SVG DAG), cost/token bar charts, execution timeline, changelog, and knowledge base. All CSS and JS are inlined — no external dependencies. Printable to PDF from any browser. +For shareable reports outside the terminal, use `/sf export --html`. This generates a self-contained HTML file in `.sf/reports/` with the same data as the TUI visualizer — progress tree, dependency graph (SVG DAG), cost/token bar charts, execution timeline, changelog, and knowledge base. All CSS and JS are inlined — no external dependencies. Printable to PDF from any browser. An auto-generated `index.html` shows all reports with progression metrics across milestones. diff --git a/docs/user-docs/web-interface.md b/docs/user-docs/web-interface.md index 23d90ca7b..56acafedc 100644 --- a/docs/user-docs/web-interface.md +++ b/docs/user-docs/web-interface.md @@ -7,7 +7,7 @@ SF includes a browser-based web interface for project management, real-time prog ## Quick Start ```bash -gsd --web +sf --web ``` This starts a local web server and opens the SF dashboard in your default browser. @@ -15,7 +15,7 @@ This starts a local web server and opens the SF dashboard in your default browse ### CLI Flags (v2.42.0) ```bash -gsd --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" +sf --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" ``` | Flag | Default | Description | diff --git a/docs/user-docs/working-in-teams.md b/docs/user-docs/working-in-teams.md index cefc9348a..65a4f02a2 100644 --- a/docs/user-docs/working-in-teams.md +++ b/docs/user-docs/working-in-teams.md @@ -9,7 +9,7 @@ SF supports multi-user workflows where several developers work on the same repos The simplest way to configure SF for team use is to set `mode: team` in your project preferences. This enables unique milestone IDs, push branches, and pre-merge checks in one setting: ```yaml -# .gsd/PREFERENCES.md (project-level, committed to git) +# .sf/PREFERENCES.md (project-level, committed to git) --- version: 1 mode: team @@ -26,23 +26,23 @@ Share planning artifacts (milestones, roadmaps, decisions) while keeping runtime ```bash # ── SF: Runtime / Ephemeral (per-developer, per-session) ────── -.gsd/auto.lock -.gsd/completed-units.json -.gsd/STATE.md -.gsd/metrics.json -.gsd/activity/ -.gsd/runtime/ -.gsd/worktrees/ -.gsd/milestones/**/continue.md -.gsd/milestones/**/*-CONTINUE.md +.sf/auto.lock +.sf/completed-units.json +.sf/STATE.md +.sf/metrics.json +.sf/activity/ +.sf/runtime/ +.sf/worktrees/ +.sf/milestones/**/continue.md +.sf/milestones/**/*-CONTINUE.md ``` **What gets shared** (committed to git): -- `.gsd/PREFERENCES.md` — project preferences -- `.gsd/PROJECT.md` — living project description -- `.gsd/REQUIREMENTS.md` — requirement contract -- `.gsd/DECISIONS.md` — architectural decisions -- `.gsd/milestones/` — roadmaps, plans, summaries, research +- `.sf/PREFERENCES.md` — project preferences +- `.sf/PROJECT.md` — living project description +- `.sf/REQUIREMENTS.md` — requirement contract +- `.sf/DECISIONS.md` — architectural decisions +- `.sf/milestones/` — roadmaps, plans, summaries, research **What stays local** (gitignored): - Lock files, metrics, state cache, runtime records, worktrees, activity logs @@ -50,7 +50,7 @@ Share planning artifacts (milestones, roadmaps, decisions) while keeping runtime ### 3. Commit the Preferences ```bash -git add .gsd/PREFERENCES.md +git add .sf/PREFERENCES.md git commit -m "chore: enable SF team workflow" ``` @@ -63,21 +63,21 @@ git: commit_docs: false ``` -This adds `.gsd/` to `.gitignore` entirely and keeps all artifacts local. The developer gets the benefits of structured planning without affecting teammates who don't use SF. +This adds `.sf/` to `.gitignore` entirely and keeps all artifacts local. The developer gets the benefits of structured planning without affecting teammates who don't use SF. ## Migrating an Existing Project -If you have an existing project with `.gsd/` blanket-ignored: +If you have an existing project with `.sf/` blanket-ignored: 1. Ensure no milestones are in progress (clean state) 2. Update `.gitignore` to use the selective pattern above -3. Add `unique_milestone_ids: true` to `.gsd/PREFERENCES.md` +3. Add `unique_milestone_ids: true` to `.sf/PREFERENCES.md` 4. Optionally rename existing milestones to use unique IDs: ``` I have turned on unique milestone ids, please update all old milestone ids to use this new format e.g. M001-abc123 where abc123 is a random 6 char lowercase alpha numeric string. Update all references in all - .gsd file contents, file names and directory names. Validate your work + .sf file contents, file names and directory names. Validate your work once done to ensure referential integrity. ``` 5. Commit @@ -86,7 +86,7 @@ If you have an existing project with `.gsd/` blanket-ignored: Multiple developers can run auto mode simultaneously on different milestones. Each developer: -- Gets their own worktree (`.gsd/worktrees//`, gitignored) +- Gets their own worktree (`.sf/worktrees//`, gitignored) - Works on a unique `milestone/` branch - Squash-merges to main independently diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 6d567ba95..6dca640b0 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -26,7 +26,7 @@ | [并行编排](./user-docs/parallel-orchestration.md) | 通过隔离的工作线程和协调机制同时运行多个 milestones | | [团队协作](./user-docs/working-in-teams.md) | 唯一 milestone ID、`.gitignore` 设置和共享规划产物 | | [技能](./user-docs/skills.md) | 内置技能、技能发现和自定义技能编写 | -| [从 v1 迁移](./user-docs/migration.md) | 将 `.planning` 目录迁移到新的 `.gsd` 格式 | -| [故障排查](./user-docs/troubleshooting.md) | 常见问题、`/gsd doctor`、`/gsd forensics` 和恢复流程 | -| [Web 界面](./user-docs/web-interface.md) | 通过 `gsd --web` 使用基于浏览器的项目管理界面 | +| [从 v1 迁移](./user-docs/migration.md) | 将 `.planning` 目录迁移到新的 `.sf` 格式 | +| [故障排查](./user-docs/troubleshooting.md) | 常见问题、`/sf doctor`、`/sf forensics` 和恢复流程 | +| [Web 界面](./user-docs/web-interface.md) | 通过 `sf --web` 使用基于浏览器的项目管理界面 | | [VS Code 扩展](../../vscode-extension/README.md) | 聊天参与者、侧边栏仪表板以及 VS Code 的 RPC 集成 | diff --git a/docs/zh-CN/user-docs/auto-mode.md b/docs/zh-CN/user-docs/auto-mode.md index 30d489986..c4bb38b6f 100644 --- a/docs/zh-CN/user-docs/auto-mode.md +++ b/docs/zh-CN/user-docs/auto-mode.md @@ -1,10 +1,10 @@ # 自动模式 -自动模式是 SF 的自主执行引擎。运行 `/gsd auto`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。 +自动模式是 SF 的自主执行引擎。运行 `/sf auto`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。 ## 工作原理 -自动模式本质上是一个**由磁盘文件驱动的状态机**。它会读取 `.gsd/STATE.md`,确定下一个工作单元,创建一个新的 agent 会话,把所有相关上下文预先内联到一个聚焦 prompt 中,再让 LLM 执行。LLM 完成后,自动模式会再次读取磁盘状态,并派发下一个工作单元。 +自动模式本质上是一个**由磁盘文件驱动的状态机**。它会读取 `.sf/STATE.md`,确定下一个工作单元,创建一个新的 agent 会话,把所有相关上下文预先内联到一个聚焦 prompt 中,再让 LLM 执行。LLM 完成后,自动模式会再次读取磁盘状态,并派发下一个工作单元。 ### 执行循环 @@ -47,7 +47,7 @@ Plan (with integrated research) → Execute (per task) → Complete → Reassess SF 支持三种 milestone 隔离模式(通过偏好设置中的 `git.isolation` 配置): -- **`worktree`**(默认):每个 milestone 都运行在 `.gsd/worktrees//` 下自己的 git worktree 中,分支名为 `milestone/`。所有 slice 工作都顺序提交,不需要切分支,也不会在 milestone 内部产生合并冲突。milestone 完成后,再整体 squash merge 回主分支,形成一个干净提交。 +- **`worktree`**(默认):每个 milestone 都运行在 `.sf/worktrees//` 下自己的 git worktree 中,分支名为 `milestone/`。所有 slice 工作都顺序提交,不需要切分支,也不会在 milestone 内部产生合并冲突。milestone 完成后,再整体 squash merge 回主分支,形成一个干净提交。 - **`branch`**:工作发生在项目根目录下的 `milestone/` 分支上。适合子模块较多、worktree 表现不佳的仓库。 - **`none`**:直接在当前分支工作。没有 worktree,也没有 milestone 分支。适合文件隔离会破坏开发工具的热重载场景。 @@ -59,9 +59,9 @@ SF 支持三种 milestone 隔离模式(通过偏好设置中的 `git.isolation ### 崩溃恢复 -自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/gsd auto` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。 +自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/sf auto` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。 -**Headless 自动重启(v2.26):** 当运行 `gsd headless auto` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。 +**Headless 自动重启(v2.26):** 当运行 `sf headless auto` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。 ### Provider 错误恢复 @@ -95,16 +95,16 @@ SF 使用滑动窗口分析来检测卡死循环。它不只是简单地统计 ### 事后取证(v2.40) -`/gsd forensics` 是一个面向自动模式失败分析的全访问 SF 调试器,提供: +`/sf forensics` 是一个面向自动模式失败分析的全访问 SF 调试器,提供: - **异常检测**:对卡死循环、成本尖峰、超时、产物缺失和崩溃做结构化识别,并标注严重级别 - **单元追踪**:最近 10 次单元执行,包含错误细节和执行时长 - **指标分析**:成本、token 数量和执行时间拆分 -- **Doctor 集成**:把 `/gsd doctor` 中的结构性健康问题一起纳入 +- **Doctor 集成**:把 `/sf doctor` 中的结构性健康问题一起纳入 - **LLM 引导调查**:启动一个拥有完整工具访问权限的 agent 会话来调查根因 ``` -/gsd forensics [optional problem description] +/sf forensics [optional problem description] ``` 更多诊断方式见 [故障排查](./troubleshooting.md)。 @@ -164,13 +164,13 @@ require_slice_discussion: true ### HTML 报告(v2.26) -每当 milestone 完成后,SF 都会在 `.gsd/reports/` 中自动生成一个自包含的 HTML 报告。报告包括项目摘要、进度树、slice 依赖图(SVG DAG)、成本 / Token 柱状图、执行时间线、变更日志和知识库。没有外部依赖,所有 CSS 和 JS 都会内联。 +每当 milestone 完成后,SF 都会在 `.sf/reports/` 中自动生成一个自包含的 HTML 报告。报告包括项目摘要、进度树、slice 依赖图(SVG DAG)、成本 / Token 柱状图、执行时间线、变更日志和知识库。没有外部依赖,所有 CSS 和 JS 都会内联。 ```yaml auto_report: true # 默认开启 ``` -你也可以随时手动执行 `/gsd export --html` 生成报告,或通过 `/gsd export --html --all`(v2.28)为所有 milestones 一次性生成报告。 +你也可以随时手动执行 `/sf export --html` 生成报告,或通过 `/sf export --html --all`(v2.28)为所有 milestones 一次性生成报告。 ### 故障恢复强化(v2.28) @@ -190,7 +190,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 实时健康可见性(v2.40) -`/gsd doctor` 发现的问题现在会实时出现在三个地方: +`/sf doctor` 发现的问题现在会实时出现在三个地方: - **Dashboard widget**:健康指示器,显示问题数量和严重级别 - **Workflow visualizer**:状态面板中展示问题 @@ -213,7 +213,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 启动 ``` -/gsd auto +/sf auto ``` ### 暂停 @@ -223,7 +223,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 恢复 ``` -/gsd auto +/sf auto ``` 自动模式会读取磁盘状态,并从中断处继续。 @@ -231,7 +231,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 停止 ``` -/gsd stop +/sf stop ``` 优雅地停止自动模式。这个命令也可以从另一个终端执行。 @@ -239,7 +239,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 引导 ``` -/gsd steer +/sf steer ``` 在不中断流水线的情况下,强制修改计划文档。修改会在下一个阶段边界生效。 @@ -247,7 +247,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 捕获 ``` -/gsd capture "add rate limiting to API endpoints" +/sf capture "add rate limiting to API endpoints" ``` 随手记录想法,不打断当前执行。Captures 会在 tasks 之间自动 triage。详见 [捕获与分流](./captures-triage.md)。 @@ -255,14 +255,14 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 可视化 ``` -/gsd visualize +/sf visualize ``` 打开工作流可视化器,交互式查看进度、依赖、指标和时间线。详见 [工作流可视化器](./visualizer.md)。 ## 仪表板 -`Ctrl+Alt+G` 或 `/gsd status` 会显示实时进度: +`Ctrl+Alt+G` 或 `/sf status` 会显示实时进度: - 当前 milestone、slice 和 task - 自动模式的已运行时间和当前阶段 diff --git a/docs/zh-CN/user-docs/captures-triage.md b/docs/zh-CN/user-docs/captures-triage.md index 241839f54..c7e2a68a4 100644 --- a/docs/zh-CN/user-docs/captures-triage.md +++ b/docs/zh-CN/user-docs/captures-triage.md @@ -9,11 +9,11 @@ Captures 允许你在自动模式执行过程中随手记录想法,而不必 在自动模式运行期间(或任何时候): ``` -/gsd capture "add rate limiting to the API endpoints" -/gsd capture "the auth flow should support OAuth, not just JWT" +/sf capture "add rate limiting to the API endpoints" +/sf capture "the auth flow should support OAuth, not just JWT" ``` -这些 capture 会追加到 `.gsd/CAPTURES.md`,并在 tasks 之间自动参与 triage。 +这些 capture 会追加到 `.sf/CAPTURES.md`,并在 tasks 之间自动参与 triage。 ## 工作原理 @@ -23,7 +23,7 @@ Captures 允许你在自动模式执行过程中随手记录想法,而不必 capture → triage → confirm → resolve → resume ``` -1. **Capture**:`/gsd capture "thought"` 会带着时间戳和唯一 ID 追加到 `.gsd/CAPTURES.md` +1. **Capture**:`/sf capture "thought"` 会带着时间戳和唯一 ID 追加到 `.sf/CAPTURES.md` 2. **Triage**:在 tasks 之间的自然衔接点(`handleAgentEnd` 中),SF 会检测待处理 capture 并进行分类 3. **Confirm**:向用户展示建议的处理方式,由用户确认或调整 4. **Resolve**:应用该处理方案(插入 task、触发重规划、延期等) @@ -56,7 +56,7 @@ LLM 会对每条 capture 进行分类并给出建议处理方案。会修改计 你也可以随时手动触发 triage: ``` -/gsd triage +/sf triage ``` 这在你积累了多条 capture,并希望在下一个自然间隙之前先处理掉它们时很有用。 @@ -74,11 +74,11 @@ Capture 上下文会自动注入到: ## Worktree 感知 -Captures 总是写回**原始项目根目录**下的 `.gsd/CAPTURES.md`,而不是 worktree 的本地副本。这样从 steering 终端记录的内容,也能被运行在 worktree 里的自动模式会话看到。 +Captures 总是写回**原始项目根目录**下的 `.sf/CAPTURES.md`,而不是 worktree 的本地副本。这样从 steering 终端记录的内容,也能被运行在 worktree 里的自动模式会话看到。 ## 命令 | 命令 | 说明 | |------|------| -| `/gsd capture "text"` | 记录一个想法(单词时引号可省略) | -| `/gsd triage` | 手动触发待处理 captures 的 triage | +| `/sf capture "text"` | 记录一个想法(单词时引号可省略) | +| `/sf triage` | 手动触发待处理 captures 的 triage | diff --git a/docs/zh-CN/user-docs/commands.md b/docs/zh-CN/user-docs/commands.md index d607a1005..9d12fc4ab 100644 --- a/docs/zh-CN/user-docs/commands.md +++ b/docs/zh-CN/user-docs/commands.md @@ -4,78 +4,78 @@ | 命令 | 说明 | |------|------| -| `/gsd` | Step mode:一次执行一个工作单元,并在每步之间暂停 | -| `/gsd next` | 显式 Step mode(与 `/gsd` 相同) | -| `/gsd auto` | 自动模式:research、plan、execute、commit,然后重复 | -| `/gsd quick` | 在不经过完整 planning 开销的情况下,执行一个带 SF 保证的 quick task(原子提交、状态跟踪) | -| `/gsd stop` | 优雅地停止自动模式 | -| `/gsd pause` | 暂停自动模式(保留状态,可用 `/gsd auto` 恢复) | -| `/gsd steer` | 在执行过程中强制修改 plan 文档 | -| `/gsd discuss` | 讨论架构和决策(可与自动模式并行使用) | -| `/gsd status` | 进度仪表板 | -| `/gsd widget` | 循环切换仪表板组件:full / small / min / off | -| `/gsd queue` | 给未来 milestones 排队和重排(自动模式中也安全) | -| `/gsd capture` | 随手记录一个想法,不打断当前流程(自动模式中可用) | -| `/gsd triage` | 手动触发待处理 captures 的 triage | -| `/gsd dispatch` | 直接派发一个指定阶段(research、plan、execute、complete、reassess、uat、replan) | -| `/gsd history` | 查看执行历史(支持 `--cost`、`--phase`、`--model` 过滤) | -| `/gsd forensics` | 全访问 SF 调试器:用于分析自动模式失败,支持结构化异常检测、单元追踪和 LLM 引导的根因分析 | -| `/gsd cleanup` | 清理 SF 状态文件和过期 worktrees | -| `/gsd visualize` | 打开工作流可视化器(进度、依赖、指标、时间线) | -| `/gsd export --html` | 为当前或已完成的 milestone 生成自包含 HTML 报告 | -| `/gsd export --html --all` | 一次性为所有 milestones 生成回顾报告 | -| `/gsd update` | 在会话内更新到最新版本 | -| `/gsd knowledge` | 添加持久化项目知识(规则、模式或经验) | -| `/gsd fast` | 为支持的模型切换 service tier(优先级 API 路由) | -| `/gsd rate` | 评价上一个单元所用模型层级(over / ok / under),帮助改进自适应路由 | -| `/gsd changelog` | 查看分类后的发行说明 | -| `/gsd logs` | 浏览活动日志、调试日志和指标 | -| `/gsd remote` | 控制远程自动模式 | -| `/gsd help` | 查看所有 SF 子命令的分类参考及说明 | +| `/sf` | Step mode:一次执行一个工作单元,并在每步之间暂停 | +| `/sf next` | 显式 Step mode(与 `/sf` 相同) | +| `/sf auto` | 自动模式:research、plan、execute、commit,然后重复 | +| `/sf quick` | 在不经过完整 planning 开销的情况下,执行一个带 SF 保证的 quick task(原子提交、状态跟踪) | +| `/sf stop` | 优雅地停止自动模式 | +| `/sf pause` | 暂停自动模式(保留状态,可用 `/sf auto` 恢复) | +| `/sf steer` | 在执行过程中强制修改 plan 文档 | +| `/sf discuss` | 讨论架构和决策(可与自动模式并行使用) | +| `/sf status` | 进度仪表板 | +| `/sf widget` | 循环切换仪表板组件:full / small / min / off | +| `/sf queue` | 给未来 milestones 排队和重排(自动模式中也安全) | +| `/sf capture` | 随手记录一个想法,不打断当前流程(自动模式中可用) | +| `/sf triage` | 手动触发待处理 captures 的 triage | +| `/sf dispatch` | 直接派发一个指定阶段(research、plan、execute、complete、reassess、uat、replan) | +| `/sf history` | 查看执行历史(支持 `--cost`、`--phase`、`--model` 过滤) | +| `/sf forensics` | 全访问 SF 调试器:用于分析自动模式失败,支持结构化异常检测、单元追踪和 LLM 引导的根因分析 | +| `/sf cleanup` | 清理 SF 状态文件和过期 worktrees | +| `/sf visualize` | 打开工作流可视化器(进度、依赖、指标、时间线) | +| `/sf export --html` | 为当前或已完成的 milestone 生成自包含 HTML 报告 | +| `/sf export --html --all` | 一次性为所有 milestones 生成回顾报告 | +| `/sf update` | 在会话内更新到最新版本 | +| `/sf knowledge` | 添加持久化项目知识(规则、模式或经验) | +| `/sf fast` | 为支持的模型切换 service tier(优先级 API 路由) | +| `/sf rate` | 评价上一个单元所用模型层级(over / ok / under),帮助改进自适应路由 | +| `/sf changelog` | 查看分类后的发行说明 | +| `/sf logs` | 浏览活动日志、调试日志和指标 | +| `/sf remote` | 控制远程自动模式 | +| `/sf help` | 查看所有 SF 子命令的分类参考及说明 | ## 配置与诊断 | 命令 | 说明 | |------|------| -| `/gsd prefs` | 模型选择、超时和预算上限 | -| `/gsd mode` | 切换工作流模式(solo / team),同时应用与 milestone ID、git 提交行为和文档相关的协调默认值 | -| `/gsd config` | 重新运行 provider 配置向导(LLM provider + 工具 key) | -| `/gsd keys` | API key 管理器:列出、添加、移除、测试、轮换、doctor | -| `/gsd doctor` | 运行时健康检查与自动修复;问题会实时显示在 widget、visualizer 和 HTML reports 中(v2.40) | -| `/gsd inspect` | 查看 SQLite DB 诊断信息 | -| `/gsd init` | 项目初始化向导:检测、配置并 bootstrap `.gsd/` | -| `/gsd setup` | 查看全局 setup 状态和配置 | -| `/gsd skill-health` | 技能生命周期仪表板:使用统计、成功率、token 趋势、过期告警 | -| `/gsd skill-health ` | 查看某个 skill 的详细信息 | -| `/gsd skill-health --declining` | 只显示被标记为表现下降的 skills | -| `/gsd skill-health --stale N` | 显示 N 天以上未使用的 skills | -| `/gsd hooks` | 查看已配置的 post-unit 和 pre-dispatch hooks | -| `/gsd run-hook` | 手动触发一个指定 hook | -| `/gsd migrate` | 将 v1 的 `.planning` 目录迁移到 `.gsd` 格式 | +| `/sf prefs` | 模型选择、超时和预算上限 | +| `/sf mode` | 切换工作流模式(solo / team),同时应用与 milestone ID、git 提交行为和文档相关的协调默认值 | +| `/sf config` | 重新运行 provider 配置向导(LLM provider + 工具 key) | +| `/sf keys` | API key 管理器:列出、添加、移除、测试、轮换、doctor | +| `/sf doctor` | 运行时健康检查与自动修复;问题会实时显示在 widget、visualizer 和 HTML reports 中(v2.40) | +| `/sf inspect` | 查看 SQLite DB 诊断信息 | +| `/sf init` | 项目初始化向导:检测、配置并 bootstrap `.sf/` | +| `/sf setup` | 查看全局 setup 状态和配置 | +| `/sf skill-health` | 技能生命周期仪表板:使用统计、成功率、token 趋势、过期告警 | +| `/sf skill-health ` | 查看某个 skill 的详细信息 | +| `/sf skill-health --declining` | 只显示被标记为表现下降的 skills | +| `/sf skill-health --stale N` | 显示 N 天以上未使用的 skills | +| `/sf hooks` | 查看已配置的 post-unit 和 pre-dispatch hooks | +| `/sf run-hook` | 手动触发一个指定 hook | +| `/sf migrate` | 将 v1 的 `.planning` 目录迁移到 `.sf` 格式 | ## Milestone 管理 | 命令 | 说明 | |------|------| -| `/gsd new-milestone` | 创建一个新的 milestone | -| `/gsd skip` | 阻止某个工作单元被自动模式派发 | -| `/gsd undo` | 回退上一个已完成单元 | -| `/gsd undo-task` | 重置某个特定 task 的完成状态(DB + markdown) | -| `/gsd reset-slice` | 重置某个 slice 及其所有 tasks(DB + markdown) | -| `/gsd park` | Park 一个 milestone,不删除,只跳过 | -| `/gsd unpark` | 重新激活一个已 park 的 milestone | -| Discard milestone | 在 `/gsd` 向导的 “Milestone actions” → “Discard” 中可用 | +| `/sf new-milestone` | 创建一个新的 milestone | +| `/sf skip` | 阻止某个工作单元被自动模式派发 | +| `/sf undo` | 回退上一个已完成单元 | +| `/sf undo-task` | 重置某个特定 task 的完成状态(DB + markdown) | +| `/sf reset-slice` | 重置某个 slice 及其所有 tasks(DB + markdown) | +| `/sf park` | Park 一个 milestone,不删除,只跳过 | +| `/sf unpark` | 重新激活一个已 park 的 milestone | +| Discard milestone | 在 `/sf` 向导的 “Milestone actions” → “Discard” 中可用 | ## 并行编排 | 命令 | 说明 | |------|------| -| `/gsd parallel start` | 分析可并行性、确认后启动 workers | -| `/gsd parallel status` | 显示所有 workers 的状态、进度和成本 | -| `/gsd parallel stop [MID]` | 停止所有 workers,或停止某个指定 milestone 的 worker | -| `/gsd parallel pause [MID]` | 暂停所有 workers,或暂停某个指定 worker | -| `/gsd parallel resume [MID]` | 恢复已暂停的 workers | -| `/gsd parallel merge [MID]` | 把已完成的 milestones 合并回 main | +| `/sf parallel start` | 分析可并行性、确认后启动 workers | +| `/sf parallel status` | 显示所有 workers 的状态、进度和成本 | +| `/sf parallel stop [MID]` | 停止所有 workers,或停止某个指定 milestone 的 worker | +| `/sf parallel pause [MID]` | 暂停所有 workers,或暂停某个指定 worker | +| `/sf parallel resume [MID]` | 恢复已暂停的 workers | +| `/sf parallel merge [MID]` | 把已完成的 milestones 合并回 main | 完整文档见 [并行编排](./parallel-orchestration.md)。 @@ -83,50 +83,50 @@ | 命令 | 说明 | |------|------| -| `/gsd start` | 启动一个 workflow template(bugfix、spike、feature、hotfix、refactor、security-audit、dep-upgrade、full-project) | -| `/gsd start resume` | 恢复一个进行中的 workflow | -| `/gsd templates` | 列出可用 workflow templates | -| `/gsd templates info ` | 查看某个 template 的详细信息 | +| `/sf start` | 启动一个 workflow template(bugfix、spike、feature、hotfix、refactor、security-audit、dep-upgrade、full-project) | +| `/sf start resume` | 恢复一个进行中的 workflow | +| `/sf templates` | 列出可用 workflow templates | +| `/sf templates info ` | 查看某个 template 的详细信息 | ## 自定义 Workflows(v2.42) | 命令 | 说明 | |------|------| -| `/gsd workflow new` | 创建一个新的 workflow definition(通过 skill) | -| `/gsd workflow run ` | 创建一个 run 并启动自动模式 | -| `/gsd workflow list` | 列出 workflow runs | -| `/gsd workflow validate ` | 校验一个 workflow YAML definition | -| `/gsd workflow pause` | 暂停自定义 workflow 的自动模式 | -| `/gsd workflow resume` | 恢复已暂停的自定义 workflow 自动模式 | +| `/sf workflow new` | 创建一个新的 workflow definition(通过 skill) | +| `/sf workflow run ` | 创建一个 run 并启动自动模式 | +| `/sf workflow list` | 列出 workflow runs | +| `/sf workflow validate ` | 校验一个 workflow YAML definition | +| `/sf workflow pause` | 暂停自定义 workflow 的自动模式 | +| `/sf workflow resume` | 恢复已暂停的自定义 workflow 自动模式 | ## 扩展 | 命令 | 说明 | |------|------| -| `/gsd extensions list` | 列出所有扩展及其状态 | -| `/gsd extensions enable ` | 启用一个被禁用的扩展 | -| `/gsd extensions disable ` | 禁用一个扩展 | -| `/gsd extensions info ` | 查看扩展详情 | +| `/sf extensions list` | 列出所有扩展及其状态 | +| `/sf extensions enable ` | 启用一个被禁用的扩展 | +| `/sf extensions disable ` | 禁用一个扩展 | +| `/sf extensions info ` | 查看扩展详情 | ## cmux 集成 | 命令 | 说明 | |------|------| -| `/gsd cmux status` | 显示 cmux 检测结果、prefs 和能力 | -| `/gsd cmux on` | 启用 cmux 集成 | -| `/gsd cmux off` | 禁用 cmux 集成 | -| `/gsd cmux notifications on/off` | 切换 cmux 桌面通知 | -| `/gsd cmux sidebar on/off` | 切换 cmux 侧边栏元数据 | -| `/gsd cmux splits on/off` | 切换 cmux subagent 可视化分屏 | +| `/sf cmux status` | 显示 cmux 检测结果、prefs 和能力 | +| `/sf cmux on` | 启用 cmux 集成 | +| `/sf cmux off` | 禁用 cmux 集成 | +| `/sf cmux notifications on/off` | 切换 cmux 桌面通知 | +| `/sf cmux sidebar on/off` | 切换 cmux 侧边栏元数据 | +| `/sf cmux splits on/off` | 切换 cmux subagent 可视化分屏 | ## GitHub Sync(v2.39) | 命令 | 说明 | |------|------| -| `/github-sync bootstrap` | 初始配置:根据当前 `.gsd/` 状态创建 GitHub Milestones、Issues 和 draft PRs | +| `/github-sync bootstrap` | 初始配置:根据当前 `.sf/` 状态创建 GitHub Milestones、Issues 和 draft PRs | | `/github-sync status` | 显示同步映射数量(milestones、slices、tasks) | -在偏好设置里启用 `github.enabled: true`。要求已安装并认证 `gh` CLI。同步映射会保存在 `.gsd/.github-sync.json`。 +在偏好设置里启用 `github.enabled: true`。要求已安装并认证 `gh` CLI。同步映射会保存在 `.sf/.github-sync.json`。 ## Git 命令 @@ -164,54 +164,54 @@ | 参数 | 说明 | |------|------| -| `gsd` | 启动新的交互式会话 | -| `gsd --continue`(`-c`) | 恢复当前目录最近一次会话 | -| `gsd --model ` | 为当前会话覆盖默认模型 | -| `gsd --print "msg"`(`-p`) | 单次 prompt 模式(无 TUI) | -| `gsd --mode ` | 非交互使用时的输出模式 | -| `gsd --list-models [search]` | 列出可用模型并退出 | -| `gsd --web [path]` | 启动基于浏览器的 Web 界面(可选项目路径) | -| `gsd --worktree`(`-w`)[name] | 在 git worktree 中启动会话(未指定时自动生成名称) | -| `gsd --no-session` | 禁用会话持久化 | -| `gsd --extension ` | 加载一个额外扩展(可重复) | -| `gsd --append-system-prompt ` | 向 system prompt 末尾追加文本 | -| `gsd --tools ` | 启用的工具列表,逗号分隔 | -| `gsd --version`(`-v`) | 输出版本并退出 | -| `gsd --help`(`-h`) | 输出帮助并退出 | -| `gsd sessions` | 交互式会话选择器:列出当前目录所有保存的会话并选择一个恢复 | -| `gsd --debug` | 启用结构化 JSONL 诊断日志,用于排查 dispatch 和 state 问题 | -| `gsd config` | 配置搜索和文档工具所需的全局 API keys(保存到 `~/.gsd/agent/auth.json`,对所有项目生效)。见 [Global API Keys](./configuration.md#global-api-keys-gsd-config)。 | -| `gsd update` | 更新到最新版本 | -| `gsd headless new-milestone` | 根据上下文文件创建新的 milestone(headless,无需 TUI) | +| `sf` | 启动新的交互式会话 | +| `sf --continue`(`-c`) | 恢复当前目录最近一次会话 | +| `sf --model ` | 为当前会话覆盖默认模型 | +| `sf --print "msg"`(`-p`) | 单次 prompt 模式(无 TUI) | +| `sf --mode ` | 非交互使用时的输出模式 | +| `sf --list-models [search]` | 列出可用模型并退出 | +| `sf --web [path]` | 启动基于浏览器的 Web 界面(可选项目路径) | +| `sf --worktree`(`-w`)[name] | 在 git worktree 中启动会话(未指定时自动生成名称) | +| `sf --no-session` | 禁用会话持久化 | +| `sf --extension ` | 加载一个额外扩展(可重复) | +| `sf --append-system-prompt ` | 向 system prompt 末尾追加文本 | +| `sf --tools ` | 启用的工具列表,逗号分隔 | +| `sf --version`(`-v`) | 输出版本并退出 | +| `sf --help`(`-h`) | 输出帮助并退出 | +| `sf sessions` | 交互式会话选择器:列出当前目录所有保存的会话并选择一个恢复 | +| `sf --debug` | 启用结构化 JSONL 诊断日志,用于排查 dispatch 和 state 问题 | +| `sf config` | 配置搜索和文档工具所需的全局 API keys(保存到 `~/.sf/agent/auth.json`,对所有项目生效)。见 [Global API Keys](./configuration.md#global-api-keys-sf-config)。 | +| `sf update` | 更新到最新版本 | +| `sf headless new-milestone` | 根据上下文文件创建新的 milestone(headless,无需 TUI) | ## Headless 模式 -`gsd headless` 可在无 TUI 的情况下运行 `/gsd` 命令,适合 CI、cron job 和脚本自动化。它会在 RPC 模式下启动一个子进程,自动回应交互式提示、检测完成状态,并用有意义的退出码退出。 +`sf headless` 可在无 TUI 的情况下运行 `/sf` 命令,适合 CI、cron job 和脚本自动化。它会在 RPC 模式下启动一个子进程,自动回应交互式提示、检测完成状态,并用有意义的退出码退出。 ```bash # 运行自动模式(默认) -gsd headless +sf headless # 运行一个单元 -gsd headless next +sf headless next # 即时 JSON 快照,无需 LLM,约 50ms -gsd headless query +sf headless query # 用于 CI 的超时参数 -gsd headless --timeout 600000 auto +sf headless --timeout 600000 auto # 强制指定一个 phase -gsd headless dispatch plan +sf headless dispatch plan # 根据上下文文件创建新 milestone,并启动自动模式 -gsd headless new-milestone --context brief.md --auto +sf headless new-milestone --context brief.md --auto # 用内联文本创建 milestone -gsd headless new-milestone --context-text "Build a REST API with auth" +sf headless new-milestone --context-text "Build a REST API with auth" # 从 stdin 管道输入上下文 -echo "Build a CLI tool" | gsd headless new-milestone --context - +echo "Build a CLI tool" | sf headless new-milestone --context - ``` | 参数 | 说明 | @@ -226,20 +226,20 @@ echo "Build a CLI tool" | gsd headless new-milestone --context - **退出码:** `0` 表示完成,`1` 表示错误或超时,`2` 表示被阻塞。 -任何 `/gsd` 子命令都可以作为位置参数使用,例如:`gsd headless status`、`gsd headless doctor`、`gsd headless dispatch execute` 等。 +任何 `/sf` 子命令都可以作为位置参数使用,例如:`sf headless status`、`sf headless doctor`、`sf headless dispatch execute` 等。 -### `gsd headless query` +### `sf headless query` 它会返回单个 JSON 对象,包含完整项目快照,无需 LLM 会话,也无需 RPC 子进程,响应几乎即时(约 50ms)。这是 orchestration 工具和脚本检查 SF 状态的推荐方式。 ```bash -gsd headless query | jq '.state.phase' +sf headless query | jq '.state.phase' # "executing" -gsd headless query | jq '.next' +sf headless query | jq '.next' # {"action":"dispatch","unitType":"execute-task","unitId":"M001/S01/T03"} -gsd headless query | jq '.cost.total' +sf headless query | jq '.cost.total' # 4.25 ``` @@ -271,21 +271,21 @@ gsd headless query | jq '.cost.total' ## MCP Server 模式 -`gsd --mode mcp` 会通过 stdin/stdout 将 SF 作为一个 [Model Context Protocol](https://modelcontextprotocol.io) server 运行。这会把所有 SF 工具(read、write、edit、bash 等)暴露给外部 AI 客户端,例如 Claude Desktop、VS Code Copilot,以及任何兼容 MCP 的宿主。 +`sf --mode mcp` 会通过 stdin/stdout 将 SF 作为一个 [Model Context Protocol](https://modelcontextprotocol.io) server 运行。这会把所有 SF 工具(read、write、edit、bash 等)暴露给外部 AI 客户端,例如 Claude Desktop、VS Code Copilot,以及任何兼容 MCP 的宿主。 ```bash # 以 MCP server 模式启动 SF -gsd --mode mcp +sf --mode mcp ``` 服务会注册 agent 会话中的全部工具,并把 MCP 的 `tools/list` 与 `tools/call` 请求映射到 SF 的工具定义上。连接会一直保持,直到底层 transport 关闭。 ## 会话内更新 -`/gsd update` 会检查 npm 上是否有更新版本,并在不离开当前会话的情况下完成安装。 +`/sf update` 会检查 npm 上是否有更新版本,并在不离开当前会话的情况下完成安装。 ```bash -/gsd update +/sf update # Current version: v2.36.0 # Checking npm registry... # Updated to v2.37.0. Restart SF to use the new version. @@ -295,14 +295,14 @@ gsd --mode mcp ## 导出 -`/gsd export` 用于导出 milestone 工作报告。 +`/sf export` 用于导出 milestone 工作报告。 ```bash # 为当前 active milestone 生成 HTML 报告 -/gsd export --html +/sf export --html # 一次性为所有 milestones 生成回顾报告 -/gsd export --html --all +/sf export --html --all ``` -报告会保存到 `.gsd/reports/`,并生成一个可浏览的 `index.html`,链接到所有已生成的快照。 +报告会保存到 `.sf/reports/`,并生成一个可浏览的 `index.html`,链接到所有已生成的快照。 diff --git a/docs/zh-CN/user-docs/configuration.md b/docs/zh-CN/user-docs/configuration.md index 8423847cb..ab40bfdae 100644 --- a/docs/zh-CN/user-docs/configuration.md +++ b/docs/zh-CN/user-docs/configuration.md @@ -1,20 +1,20 @@ # 配置 -SF 偏好设置保存在 `~/.gsd/PREFERENCES.md`(全局)或 `.gsd/PREFERENCES.md`(项目级)中。可以通过 `/gsd prefs` 进行交互式管理。 +SF 偏好设置保存在 `~/.sf/PREFERENCES.md`(全局)或 `.sf/PREFERENCES.md`(项目级)中。可以通过 `/sf prefs` 进行交互式管理。 -## `/gsd prefs` 命令 +## `/sf prefs` 命令 | 命令 | 说明 | |------|------| -| `/gsd prefs` | 打开全局偏好设置向导(默认) | -| `/gsd prefs global` | 全局偏好设置交互向导(`~/.gsd/PREFERENCES.md`) | -| `/gsd prefs project` | 项目偏好设置交互向导(`.gsd/PREFERENCES.md`) | -| `/gsd prefs status` | 显示当前偏好文件、合并后的值以及 skill 解析状态 | -| `/gsd prefs wizard` | `/gsd prefs global` 的别名 | -| `/gsd prefs setup` | `/gsd prefs wizard` 的别名;若偏好文件不存在会自动创建 | -| `/gsd prefs import-claude` | 将 Claude marketplace plugins 和 skills 以命名空间化的 SF 组件形式导入 | -| `/gsd prefs import-claude global` | 导入到全局作用域 | -| `/gsd prefs import-claude project` | 导入到项目作用域 | +| `/sf prefs` | 打开全局偏好设置向导(默认) | +| `/sf prefs global` | 全局偏好设置交互向导(`~/.sf/PREFERENCES.md`) | +| `/sf prefs project` | 项目偏好设置交互向导(`.sf/PREFERENCES.md`) | +| `/sf prefs status` | 显示当前偏好文件、合并后的值以及 skill 解析状态 | +| `/sf prefs wizard` | `/sf prefs global` 的别名 | +| `/sf prefs setup` | `/sf prefs wizard` 的别名;若偏好文件不存在会自动创建 | +| `/sf prefs import-claude` | 将 Claude marketplace plugins 和 skills 以命名空间化的 SF 组件形式导入 | +| `/sf prefs import-claude global` | 导入到全局作用域 | +| `/sf prefs import-claude project` | 导入到项目作用域 | ## 偏好文件格式 @@ -42,8 +42,8 @@ token_profile: balanced | 作用域 | 路径 | 适用范围 | |--------|------|----------| -| 全局 | `~/.gsd/PREFERENCES.md` | 所有项目 | -| 项目 | `.gsd/PREFERENCES.md` | 仅当前项目 | +| 全局 | `~/.sf/PREFERENCES.md` | 所有项目 | +| 项目 | `.sf/PREFERENCES.md` | 仅当前项目 | **合并规则:** @@ -51,13 +51,13 @@ token_profile: balanced - **数组字段**(`always_use_skills` 等):拼接,顺序为全局在前、项目在后 - **对象字段**(`models`、`git`、`auto_supervisor`):浅合并,项目级按 key 覆盖 - -## 全局 API Keys(`/gsd config`) + +## 全局 API Keys(`/sf config`) -工具 API keys 会全局保存在 `~/.gsd/agent/auth.json` 中,并自动应用到所有项目。只需用 `/gsd config` 配置一次,无需在每个项目里维护 `.env`。 +工具 API keys 会全局保存在 `~/.sf/agent/auth.json` 中,并自动应用到所有项目。只需用 `/sf config` 配置一次,无需在每个项目里维护 `.env`。 ```bash -/gsd config +/sf config ``` 这会打开一个交互式向导,显示哪些 key 已配置、哪些仍缺失。你可以选择一个工具并输入相应的 key。 @@ -72,7 +72,7 @@ token_profile: balanced ### 工作方式 -1. `/gsd config` 会把 keys 保存到 `~/.gsd/agent/auth.json` +1. `/sf config` 会把 keys 保存到 `~/.sf/agent/auth.json` 2. 每次会话启动时,`loadToolApiKeys()` 都会读取该文件并设置环境变量 3. 这些 keys 对所有项目生效,无需单独配置 4. 环境变量(例如 `export BRAVE_API_KEY=...`)优先级高于保存下来的 keys @@ -87,12 +87,12 @@ SF 可以连接配置在项目文件中的外部 MCP servers。这适合接入 SF 会从以下项目本地路径读取 MCP client 配置: - `.mcp.json` -- `.gsd/mcp.json` +- `.sf/mcp.json` 如果两个文件都存在,会按 server 名称做合并,先找到的定义优先。通常建议: - 把你愿意提交到仓库的共享 MCP 配置放在 `.mcp.json` -- 把仅本机使用、不希望共享的 MCP 配置放在 `.gsd/mcp.json` +- 把仅本机使用、不希望共享的 MCP 配置放在 `.sf/mcp.json` ### 支持的 transport @@ -150,15 +150,15 @@ mcp_call(server="my-server", tool="", args={...}) - 尽量为本地可执行文件和脚本使用绝对路径 - 对于 `stdio` servers,优先在 MCP 配置里显式设置需要的环境变量,而不是依赖交互式 shell profile -- SF 和 `gsd-mcp-server` 都会自动加载保存在 `~/.gsd/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据 +- SF 和 `sf-mcp-server` 都会自动加载保存在 `~/.sf/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据 - 如果某个 server 是团队共享且适合提交到仓库,通常更适合放在 `.mcp.json` -- 如果某个 server 依赖本机路径、个人服务或本地 secrets,更适合放在 `.gsd/mcp.json` +- 如果某个 server 依赖本机路径、个人服务或本地 secrets,更适合放在 `.sf/mcp.json` ## 环境变量 | 变量 | 默认值 | 说明 | |------|--------|------| -| `SF_HOME` | `~/.gsd` | 全局 SF 目录。除非单独覆盖,否则其它路径都从这里派生。影响偏好、skills、sessions 以及项目状态。(v2.39) | +| `SF_HOME` | `~/.sf` | 全局 SF 目录。除非单独覆盖,否则其它路径都从这里派生。影响偏好、skills、sessions 以及项目状态。(v2.39) | | `SF_PROJECT_ID` | (自动哈希) | 覆盖自动生成的项目身份哈希。这样项目状态会写入 `$SF_HOME/projects//`,而不是计算出的哈希目录。适用于 CI/CD 或多个克隆共享状态。(v2.39) | | `SF_STATE_DIR` | `$SF_HOME` | 项目状态根目录。控制 `projects//` 的创建位置。对项目状态的优先级高于 `SF_HOME`。 | | `SF_CODING_AGENT_DIR` | `$SF_HOME/agent` | agent 目录,包含托管资源、扩展和 auth。对 agent 相关路径的优先级高于 `SF_HOME`。 | @@ -193,13 +193,13 @@ models: ### 自定义 Model 定义(`models.json`) -你可以在 `~/.gsd/agent/models.json` 里定义自定义 models 和 providers。这允许你添加默认注册表里没有的 models,适合自托管 endpoints(Ollama、vLLM、LM Studio)、微调模型、代理,或者刚发布的新 provider。 +你可以在 `~/.sf/agent/models.json` 里定义自定义 models 和 providers。这允许你添加默认注册表里没有的 models,适合自托管 endpoints(Ollama、vLLM、LM Studio)、微调模型、代理,或者刚发布的新 provider。 SF 读取 `models.json` 的顺序如下: -1. `~/.gsd/agent/models.json`:主位置(SF) +1. `~/.sf/agent/models.json`:主位置(SF) 2. `~/.pi/agent/models.json`:回退位置(Pi) -3. 如果两者都不存在,则创建 `~/.gsd/agent/models.json` +3. 如果两者都不存在,则创建 `~/.sf/agent/models.json` **本地 models(Ollama)的快速示例:** @@ -243,7 +243,7 @@ models: | 扩展 | Provider | Models | 安装命令 | |------|----------|--------|----------| -| [`pi-dashscope`](https://www.npmjs.com/package/pi-dashscope) | Alibaba DashScope(ModelStudio) | Qwen3、GLM-5、MiniMax M2.5、Kimi K2.5 | `gsd install npm:pi-dashscope` | +| [`pi-dashscope`](https://www.npmjs.com/package/pi-dashscope) | Alibaba DashScope(ModelStudio) | Qwen3、GLM-5、MiniMax M2.5、Kimi K2.5 | `sf install npm:pi-dashscope` | 对于 DashScope models,更推荐使用社区扩展而不是内置的 `alibaba-coding-plan` provider,因为前者会走正确的 OpenAI-compatible endpoint,并包含适配 thinking mode 的 per-model compatibility flags。 @@ -372,7 +372,7 @@ verification_max_retries: 2 # 最大重试次数(默认:2) **允许特定内部主机:** -如果你确实需要 agent 访问内网 URL(例如自托管文档、VPN 后的内部 API),可以在全局设置 `~/.gsd/agent/settings.json` 中添加 `fetchAllowedUrls`: +如果你确实需要 agent 访问内网 URL(例如自托管文档、VPN 后的内部 API),可以在全局设置 `~/.sf/agent/settings.json` 中添加 `fetchAllowedUrls`: ```json { @@ -398,7 +398,7 @@ export SF_FETCH_ALLOWED_URLS="internal-docs.company.com,192.168.1.50" auto_report: true # 默认:true ``` -报告会以自包含 HTML 文件的形式写入 `.gsd/reports/`,所有 CSS / JS 都内嵌。 +报告会以自包含 HTML 文件的形式写入 `.sf/reports/`,所有 CSS / JS 都内嵌。 ### `unique_milestone_ids` @@ -424,9 +424,9 @@ git: main_branch: main # 主分支名称 merge_strategy: squash # worktree 分支合并方式:"squash" 或 "merge" isolation: worktree # git isolation:"worktree"、"branch" 或 "none" - commit_docs: true # 是否把 .gsd/ 产物提交到 git(设为 false 时仅保留本地) + commit_docs: true # 是否把 .sf/ 产物提交到 git(设为 false 时仅保留本地) manage_gitignore: true # 设为 false 时,SF 不再修改 .gitignore - worktree_post_create: .gsd/hooks/post-worktree-create # worktree 创建后执行的脚本 + worktree_post_create: .sf/hooks/post-worktree-create # worktree 创建后执行的脚本 auto_pr: false # milestone 完成时自动创建 PR(要求 push_branches) pr_target_branch: develop # 自动创建 PR 的目标分支(默认:main branch) ``` @@ -442,7 +442,7 @@ git: | `main_branch` | string | `"main"` | 主分支名称 | | `merge_strategy` | string | `"squash"` | worktree 分支合并方式:`"squash"`(合并为单个提交)或 `"merge"`(保留单独提交) | | `isolation` | string | `"worktree"` | 自动模式隔离方式:`"worktree"`(独立目录)、`"branch"`(直接在项目根目录工作,适合子模块多的仓库)、`"none"`(无隔离,直接提交到当前分支) | -| `commit_docs` | boolean | `true` | 是否把 `.gsd/` planning 产物提交到 git。设为 `false` 则仅保留本地 | +| `commit_docs` | boolean | `true` | 是否把 `.sf/` planning 产物提交到 git。设为 `false` 则仅保留本地 | | `manage_gitignore` | boolean | `true` | 设为 `false` 后,SF 将完全不修改 `.gitignore`,不会添加基础规则,也不会做自愈 | | `worktree_post_create` | string | (无) | worktree 创建后执行的脚本。环境变量中会传入 `SOURCE_DIR` 和 `WORKTREE_DIR` | | `auto_pr` | boolean | `false` | milestone 完成时自动创建 pull request。要求 `auto_push: true` 且已安装认证 `gh` CLI | @@ -454,7 +454,7 @@ git: ```yaml git: - worktree_post_create: .gsd/hooks/post-worktree-create + worktree_post_create: .sf/hooks/post-worktree-create ``` 脚本会收到两个环境变量: @@ -462,7 +462,7 @@ git: - `SOURCE_DIR`:原始项目根目录 - `WORKTREE_DIR`:新创建的 worktree 路径 -示例 hook(`.gsd/hooks/post-worktree-create`): +示例 hook(`.sf/hooks/post-worktree-create`): ```bash #!/bin/bash @@ -508,7 +508,7 @@ GitHub 同步配置。启用后,SF 会自动把 milestones、slices 和 tasks github: enabled: true repo: "owner/repo" # 省略时从 git remote 自动检测 - labels: [gsd, auto-generated] # 应用到创建出的 issues / PRs 的标签 + labels: [sf, auto-generated] # 应用到创建出的 issues / PRs 的标签 project: "Project ID" # 可选的 GitHub Project board ``` @@ -522,7 +522,7 @@ github: **要求:** - 已安装并认证 `gh` CLI(`gh auth login`) -- 同步映射会保存在 `.gsd/.github-sync.json` +- 同步映射会保存在 `.sf/.github-sync.json` - 具备速率限制感知:当 GitHub API rate limit 偏低时会跳过同步 **命令:** @@ -662,13 +662,13 @@ custom_instructions: - "Prefer functional patterns over classes" ``` -如果是项目特有知识(模式、坑点、经验),请优先放到 `.gsd/KNOWLEDGE.md` 中,因为它会自动注入每个 agent prompt。你也可以通过 `/gsd knowledge rule|pattern|lesson ` 添加。 +如果是项目特有知识(模式、坑点、经验),请优先放到 `.sf/KNOWLEDGE.md` 中,因为它会自动注入每个 agent prompt。你也可以通过 `/sf knowledge rule|pattern|lesson ` 添加。 ### `RUNTIME.md`:运行时上下文(v2.39) -你可以在 `.gsd/RUNTIME.md` 中声明项目级运行时上下文。这个文件会内联进 task execution prompt,让 agent 能准确知道运行环境,而不必靠猜测路径或 URL。 +你可以在 `.sf/RUNTIME.md` 中声明项目级运行时上下文。这个文件会内联进 task execution prompt,让 agent 能准确知道运行环境,而不必靠猜测路径或 URL。 -**位置:** `.gsd/RUNTIME.md` +**位置:** `.sf/RUNTIME.md` **示例:** @@ -721,7 +721,7 @@ context_management: ### `service_tier`(v2.42) -OpenAI 支持模型的 service tier 偏好。可通过 `/gsd fast` 切换。 +OpenAI 支持模型的 service tier 偏好。可通过 `/sf fast` 切换。 | 值 | 行为 | |----|------| @@ -735,7 +735,7 @@ service_tier: priority ### `forensics_dedup`(v2.43) -可选启用:在 `/gsd forensics` 提交 issue 之前,先搜索现有 issues 和 PRs。会额外消耗一些 AI tokens。 +可选启用:在 `/sf forensics` 提交 issue 之前,先搜索现有 issues 和 PRs。会额外消耗一些 AI tokens。 ```yaml forensics_dedup: true # 默认:false @@ -836,7 +836,7 @@ notifications: auto_visualize: true # Service tier -service_tier: priority # "priority" or "flex" (for /gsd fast) +service_tier: priority # "priority" or "flex" (for /sf fast) # Diagnostics forensics_dedup: true # deduplicate before filing forensics issues diff --git a/docs/zh-CN/user-docs/cost-management.md b/docs/zh-CN/user-docs/cost-management.md index 6e4769d2e..c4257211c 100644 --- a/docs/zh-CN/user-docs/cost-management.md +++ b/docs/zh-CN/user-docs/cost-management.md @@ -12,11 +12,11 @@ SF 会跟踪自动模式中每个派发工作单元的 Token 使用量和成本 - **工具调用数**:工具调用次数 - **消息数量**:assistant 与 user 消息数 -数据保存在 `.gsd/metrics.json` 中,并且可跨会话持续存在。 +数据保存在 `.sf/metrics.json` 中,并且可跨会话持续存在。 ### 查看成本 -**仪表板**:按 `Ctrl+Alt+G` 或执行 `/gsd status` 可查看实时成本拆分。 +**仪表板**:按 `Ctrl+Alt+G` 或执行 `/sf status` 可查看实时成本拆分。 **可用聚合维度:** @@ -86,9 +86,9 @@ Projected remaining: $12.40 ($6.20/slice avg × 2 remaining) ## 建议 - 先用 `balanced` 配置,并设置一个较宽松的 `budget_ceiling` 来建立成本基线 -- 完成几个 slices 后查看 `/gsd status`,确认每个 slice 的平均成本 +- 完成几个 slices 后查看 `/sf status`,确认每个 slice 的平均成本 - 对于已知流程、重复性高的工作,切换到 `budget` 配置 - 只有在做架构决策时才建议使用 `quality` - 可以通过按阶段选模型,只在 planning 使用 Opus,而在 execution 保持 Sonnet - 开启 `dynamic_routing`,让简单 task 自动下沉到更便宜的模型,详见 [动态模型路由](./dynamic-model-routing.md) -- 使用 `/gsd visualize` 的 Metrics 标签页查看预算具体花在了哪里 +- 使用 `/sf visualize` 的 Metrics 标签页查看预算具体花在了哪里 diff --git a/docs/zh-CN/user-docs/custom-models.md b/docs/zh-CN/user-docs/custom-models.md index fc24f40d4..50de221d4 100644 --- a/docs/zh-CN/user-docs/custom-models.md +++ b/docs/zh-CN/user-docs/custom-models.md @@ -1,6 +1,6 @@ # 自定义模型 -通过 `~/.gsd/agent/models.json` 添加自定义 providers 和 models(Ollama、vLLM、LM Studio、代理等)。 +通过 `~/.sf/agent/models.json` 添加自定义 providers 和 models(Ollama、vLLM、LM Studio、代理等)。 ## 目录 @@ -149,7 +149,7 @@ Shell 命令(`!command`)只能执行一组已知的凭据工具。只有以 **自定义允许列表:** -如果你使用的凭据工具不在默认列表中,可以在全局设置(`~/.gsd/agent/settings.json`)里覆盖: +如果你使用的凭据工具不在默认列表中,可以在全局设置(`~/.sf/agent/settings.json`)里覆盖: ```json { @@ -165,7 +165,7 @@ Shell 命令(`!command`)只能执行一组已知的凭据工具。只有以 export SF_ALLOWED_COMMAND_PREFIXES="pass,op,sops,doppler" ``` -> **注意:** 这是一个仅全局生效的设置。项目级 settings.json(`/.gsd/settings.json`)不能覆盖命令 allowlist,以防克隆下来的仓库提升命令执行权限。 +> **注意:** 这是一个仅全局生效的设置。项目级 settings.json(`/.sf/settings.json`)不能覆盖命令 allowlist,以防克隆下来的仓库提升命令执行权限。 ### 自定义 Headers diff --git a/docs/zh-CN/user-docs/dynamic-model-routing.md b/docs/zh-CN/user-docs/dynamic-model-routing.md index 6d0d90a3e..6b73ac53b 100644 --- a/docs/zh-CN/user-docs/dynamic-model-routing.md +++ b/docs/zh-CN/user-docs/dynamic-model-routing.md @@ -260,7 +260,7 @@ pi.on("before_model_select", async (event) => { ### 自适应学习 -路由历史(`.gsd/routing-history.json`)会按 unit type 和 tier 记录成功 / 失败情况。如果某种模式下某个 tier 的失败率超过 20%,未来相似分类会自动上调一个 tier。用户反馈(`over` / `under` / `ok`)的权重是自动结果的 2 倍。 +路由历史(`.sf/routing-history.json`)会按 unit type 和 tier 记录成功 / 失败情况。如果某种模式下某个 tier 的失败率超过 20%,未来相似分类会自动上调一个 tier。用户反馈(`over` / `under` / `ok`)的权重是自动结果的 2 倍。 ## 与 Token Profile 的关系 diff --git a/docs/zh-CN/user-docs/getting-started.md b/docs/zh-CN/user-docs/getting-started.md index dfb2e95d5..d74ab7300 100644 --- a/docs/zh-CN/user-docs/getting-started.md +++ b/docs/zh-CN/user-docs/getting-started.md @@ -54,7 +54,7 @@ npm install -g sf-run export ANTHROPIC_API_KEY="sk-ant-..." # 选项 B:使用内置配置向导 -gsd config +sf config ``` 如果想永久保存这个 key,把 export 语句写入 `~/.zshrc`: @@ -70,24 +70,24 @@ source ~/.zshrc ```bash cd ~/my-project # 进入任意项目目录 -gsd # 启动一个会话 +sf # 启动一个会话 ``` **第 7 步:确认一切正常:** ```bash -gsd --version # 输出已安装版本 +sf --version # 输出已安装版本 ``` 进入会话后,输入 `/model` 以确认你的 LLM 已成功连接。 -> **Apple Silicon PATH 修复:** 如果安装后找不到 `gsd`,可能是 npm 的全局 bin 目录没有加入 PATH: +> **Apple Silicon PATH 修复:** 如果安装后找不到 `sf`,可能是 npm 的全局 bin 目录没有加入 PATH: > ```bash > echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc > source ~/.zshrc > ``` -> **oh-my-zsh 冲突:** oh-my-zsh 的 git 插件定义了 `alias gsd='git svn dcommit'`。可在 `~/.zshrc` 中加入 `unalias gsd 2>/dev/null`,或者改用 `gsd-cli`。 +> **oh-my-zsh 冲突:** oh-my-zsh 的 git 插件定义了 `alias sf='git svn dcommit'`。可在 `~/.zshrc` 中加入 `unalias sf 2>/dev/null`,或者改用 `sf-cli`。 --- @@ -126,7 +126,7 @@ npm install -g sf-run $env:ANTHROPIC_API_KEY = "sk-ant-..." # 选项 B:使用内置配置向导 -gsd config +sf config ``` 如果要永久保存该 key,可在系统设置的环境变量中添加,或者执行: @@ -141,13 +141,13 @@ gsd config ```powershell cd C:\Users\you\my-project # 进入任意项目目录 -gsd # 启动一个会话 +sf # 启动一个会话 ``` **第 7 步:确认一切正常:** ```powershell -gsd --version # 输出已安装版本 +sf --version # 输出已安装版本 ``` 进入会话后,输入 `/model` 以确认你的 LLM 已成功连接。 @@ -160,7 +160,7 @@ gsd --version # 输出已安装版本 > **Windows 提示:** > - 建议使用 **Windows Terminal** 或 **PowerShell**,体验最佳。Command Prompt 也能用,但颜色支持较弱。 -> - 如果 `gsd` 无法识别,先重启终端。Windows 需要新开终端才能读取更新后的 PATH。 +> - 如果 `sf` 无法识别,先重启终端。Windows 需要新开终端才能读取更新后的 PATH。 > - **WSL2** 也可用,安装 WSL 后,在发行版内部按 Linux 说明继续。 --- @@ -230,7 +230,7 @@ npm install -g sf-run export ANTHROPIC_API_KEY="sk-ant-..." # 选项 B:使用内置配置向导 -gsd config +sf config ``` 如果想永久保存这个 key,把 export 语句写到 `~/.bashrc`(或 `~/.zshrc`)中: @@ -246,13 +246,13 @@ source ~/.bashrc ```bash cd ~/my-project # 进入任意项目目录 -gsd # 启动一个会话 +sf # 启动一个会话 ``` **第 6 步:确认一切正常:** ```bash -gsd --version # 输出已安装版本 +sf --version # 输出已安装版本 ``` 进入会话后,输入 `/model` 以确认你的 LLM 已成功连接。 @@ -280,21 +280,21 @@ gsd --version # 输出已安装版本 ```bash git clone https://github.com/singularity-forge/sf-run.git -cd gsd-2/docker +cd sf-2/docker ``` **第 3 步:创建并进入沙箱:** ```bash -docker sandbox create --template . --name gsd-sandbox -docker sandbox exec -it gsd-sandbox bash +docker sandbox create --template . --name sf-sandbox +docker sandbox exec -it sf-sandbox bash ``` **第 4 步:设置 API key 并运行 SF:** ```bash export ANTHROPIC_API_KEY="sk-ant-..." -gsd auto "implement the feature described in issue #42" +sf auto "implement the feature described in issue #42" ``` 完整的配置、资源限制和 compose 文件请见 [Docker Sandbox 文档](../../../docker/README.md)。 @@ -317,23 +317,23 @@ gsd auto "implement the feature described in issue #42" ## 两种工作方式 -### 步骤模式 — `/gsd` +### 步骤模式 — `/sf` -在会话内输入 `/gsd`。SF 会一次执行一个工作单元,并在每一步之间暂停,通过向导展示刚完成了什么、下一步是什么。 +在会话内输入 `/sf`。SF 会一次执行一个工作单元,并在每一步之间暂停,通过向导展示刚完成了什么、下一步是什么。 -- **没有 `.gsd/` 目录**:启动讨论流程,先收集你的项目愿景 +- **没有 `.sf/` 目录**:启动讨论流程,先收集你的项目愿景 - **已有 milestone,但没有 roadmap**:讨论或研究该 milestone - **roadmap 已存在,仍有待完成的 slices**:规划下一个 slice 或执行一个 task - **进行到一半的 task**:从上次停下的地方继续 步骤模式会让你始终留在回路中,在每一步之间查看和确认输出。 -### 自动模式 — `/gsd auto` +### 自动模式 — `/sf auto` -输入 `/gsd auto` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。 +输入 `/sf auto` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。 ``` -/gsd auto +/sf auto ``` 完整细节请见 [自动模式](./auto-mode.md)。 @@ -347,20 +347,20 @@ gsd auto "implement the feature described in issue #42" **终端 1:让它构建** ```bash -gsd -/gsd auto +sf +/sf auto ``` **终端 2:在它工作时进行引导** ```bash -gsd -/gsd discuss # 讨论架构决策 -/gsd status # 查看进度 -/gsd queue # 排队下一个 milestone +sf +/sf discuss # 讨论架构决策 +/sf status # 查看进度 +/sf queue # 排队下一个 milestone ``` -两个终端都会读写同一套 `.gsd/` 文件。你在终端 2 里做出的决策,会在下一个阶段边界被自动拾取。 +两个终端都会读写同一套 `.sf/` 文件。你在终端 2 里做出的决策,会在下一个阶段边界被自动拾取。 --- @@ -374,10 +374,10 @@ Milestone → 一个可交付版本(4-10 个 slice) 铁律是:**一个 task 必须能装进一个上下文窗口。** 装不下,就说明它应该拆成两个 task。 -所有状态都保存在 `.gsd/` 中: +所有状态都保存在 `.sf/` 中: ``` -.gsd/ +.sf/ PROJECT.md — 项目当前是什么 REQUIREMENTS.md — 需求契约 DECISIONS.md — 追加式架构决策记录 @@ -398,7 +398,7 @@ Milestone → 一个可交付版本(4-10 个 slice) SF 也提供 VS Code 扩展。你可以从扩展市场安装(publisher: FluxLabs),或者在 VS Code 扩展面板中直接搜索 “SF”: -- **`@gsd` 聊天参与者**:在 VS Code Chat 中直接与 agent 对话 +- **`@sf` 聊天参与者**:在 VS Code Chat 中直接与 agent 对话 - **侧边栏仪表板**:显示连接状态、模型信息、Token 使用量 - **完整命令面板**:启动 / 停止 agent、切换模型、导出会话 @@ -411,7 +411,7 @@ CLI(`sf-run`)需要先安装好,扩展会通过 RPC 与其连接。 SF 也提供一个基于浏览器的可视化项目管理界面: ```bash -gsd --web +sf --web ``` 详见 [Web 界面](./web-interface.md)。 @@ -421,7 +421,7 @@ gsd --web ## 恢复会话 ```bash -gsd --continue # 或 gsd -c +sf --continue # 或 sf -c ``` 会恢复当前目录最近一次会话。 @@ -429,7 +429,7 @@ gsd --continue # 或 gsd -c 浏览所有保存过的会话: ```bash -gsd sessions +sf sessions ``` --- @@ -445,7 +445,7 @@ npm update -g sf-run 或者在会话中执行: ``` -/gsd update +/sf update ``` --- @@ -454,11 +454,11 @@ npm update -g sf-run | 问题 | 解决方式 | |------|----------| -| `command not found: gsd` | 把 npm 全局 bin 目录加入 PATH(见上面的系统说明) | -| `gsd` 实际执行了 `git svn dcommit` | oh-my-zsh 冲突,执行 `unalias gsd` 或改用 `gsd-cli` | +| `command not found: sf` | 把 npm 全局 bin 目录加入 PATH(见上面的系统说明) | +| `sf` 实际执行了 `git svn dcommit` | oh-my-zsh 冲突,执行 `unalias sf` 或改用 `sf-cli` | | `npm install -g sf-run` 权限错误 | 修复 npm prefix(见 Linux 说明)或改用 nvm | -| 无法连接到 LLM | 用 `gsd config` 检查 API key,并确认网络可用 | -| `gsd` 启动时卡住 | 检查 Node.js 版本:`node --version`(需要 22+) | +| 无法连接到 LLM | 用 `sf config` 检查 API key,并确认网络可用 | +| `sf` 启动时卡住 | 检查 Node.js 版本:`node --version`(需要 22+) | 更多问题见 [故障排查](./troubleshooting.md)。 diff --git a/docs/zh-CN/user-docs/git-strategy.md b/docs/zh-CN/user-docs/git-strategy.md index 6520e6f56..9a77a8659 100644 --- a/docs/zh-CN/user-docs/git-strategy.md +++ b/docs/zh-CN/user-docs/git-strategy.md @@ -8,13 +8,13 @@ SF 支持三种隔离模式,通过 `git.isolation` 偏好设置: | 模式 | 工作目录 | 分支 | 适用场景 | |------|----------|------|----------| -| `worktree`(默认) | `.gsd/worktrees//` | `milestone/` | 大多数项目,milestones 之间文件完全隔离 | +| `worktree`(默认) | `.sf/worktrees//` | `milestone/` | 大多数项目,milestones 之间文件完全隔离 | | `branch` | 项目根目录 | `milestone/` | 子模块较多、worktree 表现不佳的仓库 | | `none` | 项目根目录 | 当前分支(不建 milestone 分支) | 热重载工作流中,文件隔离会破坏开发工具的场景 | ### `worktree` 模式(默认) -每个 milestone 都会在 `.gsd/worktrees//` 下拥有自己的 git worktree,对应一个 `milestone/` 分支。所有执行都发生在该 worktree 中。完成后,worktree 会被 squash merge 回主分支,形成一个干净的提交,然后清理对应 worktree 和分支。 +每个 milestone 都会在 `.sf/worktrees//` 下拥有自己的 git worktree,对应一个 `milestone/` 分支。所有执行都发生在该 worktree 中。完成后,worktree 会被 squash merge 回主分支,形成一个干净的提交,然后清理对应 worktree 和分支。 这提供了完整的文件隔离,某个 milestone 的变更不会干扰你的主工作副本。 @@ -95,8 +95,8 @@ SF-Task: M001/S01/T02 自动模式会自动创建并管理 worktrees: -1. milestone 启动时,在 `.gsd/worktrees//` 创建 worktree,并切到 `milestone/` 分支 -2. 将 `.gsd/milestones/` 下的规划产物复制到该 worktree +1. milestone 启动时,在 `.sf/worktrees//` 创建 worktree,并切到 `milestone/` 分支 +2. 将 `.sf/milestones/` 下的规划产物复制到该 worktree 3. 所有执行都发生在 worktree 内部 4. milestone 完成后,把该 worktree squash merge 回集成分支 5. 删除 worktree 和对应分支 @@ -148,7 +148,7 @@ git: pre_merge_check: false # 合并前校验 commit_type: feat # 覆盖提交类型前缀 main_branch: main # 主分支名称 - commit_docs: true # 将 .gsd/ 提交到 git + commit_docs: true # 将 .sf/ 提交到 git isolation: worktree # "worktree"、"branch" 或 "none" auto_pr: false # milestone 完成时自动创建 PR pr_target_branch: develop # PR 目标分支(默认 main) @@ -169,7 +169,7 @@ git: ### `commit_docs: false` -当设置为 `false` 时,SF 会把 `.gsd/` 添加到 `.gitignore`,所有规划产物只保留在本地。适合只有部分成员使用 SF 的团队,或者公司要求仓库保持干净的场景。 +当设置为 `false` 时,SF 会把 `.sf/` 添加到 `.gitignore`,所有规划产物只保留在本地。适合只有部分成员使用 SF 的团队,或者公司要求仓库保持干净的场景。 ## 自愈能力 @@ -179,7 +179,7 @@ SF 内置了对常见 git 问题的自动恢复: - **过期锁文件**:移除崩溃进程残留的 `index.lock` - **孤儿 worktree**:检测并提供清理废弃 worktree 的选项(仅 worktree 模式) -可通过 `/gsd doctor` 手动检查 git 健康状态。 +可通过 `/sf doctor` 手动检查 git 健康状态。 ## 原生 Git 操作 diff --git a/docs/zh-CN/user-docs/migration.md b/docs/zh-CN/user-docs/migration.md index 0524450e3..9cf9791e9 100644 --- a/docs/zh-CN/user-docs/migration.md +++ b/docs/zh-CN/user-docs/migration.md @@ -1,15 +1,15 @@ # 从 v1 迁移 -如果你有仍在使用原始 Singularity Forge(v1)`.planning` 目录结构的项目,可以把它们迁移到 SF 的 `.gsd` 格式。 +如果你有仍在使用原始 Singularity Forge(v1)`.planning` 目录结构的项目,可以把它们迁移到 SF 的 `.sf` 格式。 ## 运行迁移 ```bash # 在项目目录内执行 -/gsd migrate +/sf migrate # 或者显式指定路径 -/gsd migrate ~/projects/my-old-project +/sf migrate ~/projects/my-old-project ``` ## 会迁移什么 @@ -42,7 +42,7 @@ 迁移完成后,用下面的命令检查输出结果: ```bash -/gsd doctor +/sf doctor ``` -它会检查 `.gsd/` 的完整性,并标出任何结构性问题。 +它会检查 `.sf/` 的完整性,并标出任何结构性问题。 diff --git a/docs/zh-CN/user-docs/node-lts-macos.md b/docs/zh-CN/user-docs/node-lts-macos.md index 42895afe2..efd12ca63 100644 --- a/docs/zh-CN/user-docs/node-lts-macos.md +++ b/docs/zh-CN/user-docs/node-lts-macos.md @@ -71,5 +71,5 @@ brew unpin node@24 ```bash node --version # v24.x.x npm install -g sf-run -gsd --version +sf --version ``` diff --git a/docs/zh-CN/user-docs/parallel-orchestration.md b/docs/zh-CN/user-docs/parallel-orchestration.md index 90311badf..648785f69 100644 --- a/docs/zh-CN/user-docs/parallel-orchestration.md +++ b/docs/zh-CN/user-docs/parallel-orchestration.md @@ -19,7 +19,7 @@ parallel: 2. 启动并行执行: ``` -/gsd parallel start +/sf parallel start ``` SF 会扫描所有 milestones,检查依赖与文件重叠,给出一份可并行性报告,并为符合条件的 milestones 启动 workers。 @@ -27,13 +27,13 @@ SF 会扫描所有 milestones,检查依赖与文件重叠,给出一份可并 3. 监控进度: ``` -/gsd parallel status +/sf parallel status ``` 4. 完成后停止: ``` -/gsd parallel stop +/sf parallel stop ``` ## 工作原理 @@ -58,7 +58,7 @@ SF 会扫描所有 milestones,检查依赖与文件重叠,给出一份可并 │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ -│ .gsd/worktrees/ .gsd/worktrees/ .gsd/worktrees/ │ +│ .sf/worktrees/ .sf/worktrees/ .sf/worktrees/ │ │ M001/ M003/ M005/ │ │ (milestone/ (milestone/ (milestone/ │ │ M001 branch) M003 branch) M005 branch) │ @@ -67,7 +67,7 @@ SF 会扫描所有 milestones,检查依赖与文件重叠,给出一份可并 ### Worker 隔离 -每个 worker 都是一个完全隔离的独立 `gsd` 进程: +每个 worker 都是一个完全隔离的独立 `sf` 进程: | 资源 | 隔离方式 | |------|----------| @@ -75,15 +75,15 @@ SF 会扫描所有 milestones,检查依赖与文件重叠,给出一份可并 | **Git 分支** | `milestone/`:每个 milestone 一条分支 | | **状态推导** | 通过 `SF_MILESTONE_LOCK` 环境变量,让 `deriveState()` 只看到被分配的 milestone | | **上下文窗口** | 独立进程:每个 worker 都有自己的 agent sessions | -| **指标** | 每个 worktree 都有自己的 `.gsd/metrics.json` | -| **崩溃恢复** | 每个 worktree 都有自己的 `.gsd/auto.lock` | +| **指标** | 每个 worktree 都有自己的 `.sf/metrics.json` | +| **崩溃恢复** | 每个 worktree 都有自己的 `.sf/auto.lock` | ### 协调方式 Workers 和 coordinator 通过基于文件的 IPC 通信: -- **会话状态文件**(`.gsd/parallel/.status.json`):worker 写入 heartbeat,coordinator 读取 -- **信号文件**(`.gsd/parallel/.signal.json`):coordinator 写信号,worker 消费 +- **会话状态文件**(`.sf/parallel/.status.json`):worker 写入 heartbeat,coordinator 读取 +- **信号文件**(`.sf/parallel/.signal.json`):coordinator 写信号,worker 消费 - **原子写入**:使用写临时文件再 rename 的方式,避免读到半成品 ## 可并行性分析 @@ -126,7 +126,7 @@ Workers 和 coordinator 通过基于文件的 IPC 通信: ## 配置 -把下面内容加到 `~/.gsd/PREFERENCES.md` 或 `.gsd/PREFERENCES.md`: +把下面内容加到 `~/.sf/PREFERENCES.md` 或 `.sf/PREFERENCES.md`: ```yaml --- @@ -143,26 +143,26 @@ parallel: | Key | 类型 | 默认值 | 说明 | |-----|------|--------|------| -| `enabled` | boolean | `false` | 总开关。只有设为 `true`,`/gsd parallel` 命令才可用。 | +| `enabled` | boolean | `false` | 总开关。只有设为 `true`,`/sf parallel` 命令才可用。 | | `max_workers` | number(1-4) | `2` | 最大并发 worker 进程数。值越高,内存与 API 预算消耗也越高。 | | `budget_ceiling` | number | 无 | 所有 workers 的聚合美元预算上限。达到后不会再派发新单元。 | | `merge_strategy` | `"per-slice"` 或 `"per-milestone"` | `"per-milestone"` | worktree 变更何时回合并到主分支。Per-milestone 会等整个 milestone 完成后再合并。 | -| `auto_merge` | `"auto"`、`"confirm"`、`"manual"` | `"confirm"` | merge-back 策略。`confirm` 会在合并前询问;`manual` 要求显式执行 `/gsd parallel merge`。 | +| `auto_merge` | `"auto"`、`"confirm"`、`"manual"` | `"confirm"` | merge-back 策略。`confirm` 会在合并前询问;`manual` 要求显式执行 `/sf parallel merge`。 | ## 命令 | 命令 | 说明 | |------|------| -| `/gsd parallel start` | 分析可并行性、确认并启动 workers | -| `/gsd parallel status` | 显示所有 workers 的状态、已完成单元和成本 | -| `/gsd parallel stop` | 停止所有 workers(发送 SIGTERM) | -| `/gsd parallel stop M002` | 停止某个指定 milestone 的 worker | -| `/gsd parallel pause` | 暂停所有 workers(完成当前单元后等待) | -| `/gsd parallel pause M002` | 暂停某个指定 worker | -| `/gsd parallel resume` | 恢复所有已暂停 workers | -| `/gsd parallel resume M002` | 恢复某个指定 worker | -| `/gsd parallel merge` | 把所有已完成 milestones 合并回 main | -| `/gsd parallel merge M002` | 只把某个指定 milestone 合并回 main | +| `/sf parallel start` | 分析可并行性、确认并启动 workers | +| `/sf parallel status` | 显示所有 workers 的状态、已完成单元和成本 | +| `/sf parallel stop` | 停止所有 workers(发送 SIGTERM) | +| `/sf parallel stop M002` | 停止某个指定 milestone 的 worker | +| `/sf parallel pause` | 暂停所有 workers(完成当前单元后等待) | +| `/sf parallel pause M002` | 暂停某个指定 worker | +| `/sf parallel resume` | 恢复所有已暂停 workers | +| `/sf parallel resume M002` | 恢复某个指定 worker | +| `/sf parallel merge` | 把所有已完成 milestones 合并回 main | +| `/sf parallel merge M002` | 只把某个指定 milestone 合并回 main | ## 信号生命周期 @@ -200,13 +200,13 @@ Workers 会在单元之间检查信号(位于 `handleAgentEnd`)。在 stop ### 冲突处理 -1. `.gsd/` 状态文件(如 `STATE.md`、`metrics.json`)会**自动解决**,默认接受 milestone 分支版本 -2. 代码冲突则会**停止并报告**。合并会暂停,并显示哪些文件冲突。你需要手动解决后,再执行 `/gsd parallel merge ` 重试 +1. `.sf/` 状态文件(如 `STATE.md`、`metrics.json`)会**自动解决**,默认接受 milestone 分支版本 +2. 代码冲突则会**停止并报告**。合并会暂停,并显示哪些文件冲突。你需要手动解决后,再执行 `/sf parallel merge ` 重试 ### 示例 ``` -/gsd parallel merge +/sf parallel merge # Merge Results @@ -214,7 +214,7 @@ Workers 会在单元之间检查信号(位于 `handleAgentEnd`)。在 stop - **M003** — CONFLICT (2 file(s)): - `src/types.ts` - `src/middleware.ts` - Resolve conflicts manually and run `/gsd parallel merge M003` to retry. + Resolve conflicts manually and run `/sf parallel merge M003` to retry. ``` ## 预算管理 @@ -229,11 +229,11 @@ Workers 会在单元之间检查信号(位于 `handleAgentEnd`)。在 stop ### Doctor 集成 -`/gsd doctor` 能检测并行会话相关问题: +`/sf doctor` 能检测并行会话相关问题: -- **过期的并行会话**:worker 进程已经死亡,但没有清理干净。Doctor 会检查 `.gsd/parallel/*.status.json` 中记录的 PID 和 heartbeat,发现失效后自动清理。 +- **过期的并行会话**:worker 进程已经死亡,但没有清理干净。Doctor 会检查 `.sf/parallel/*.status.json` 中记录的 PID 和 heartbeat,发现失效后自动清理。 -可以执行 `/gsd doctor --fix` 自动清理。 +可以执行 `/sf doctor --fix` 自动清理。 ### 过期检测 @@ -256,12 +256,12 @@ Coordinator 会在 `refreshWorkerStatuses()` 中执行 stale detection,并自 | **预算上限** | 跨所有 workers 执行聚合成本限制 | | **信号式关闭** | 通过文件信号 + SIGTERM 优雅停止 | | **Doctor 集成** | 检测并清理孤儿会话 | -| **冲突感知 merge** | 遇到代码冲突时停止;`.gsd/` 状态冲突自动解决 | +| **冲突感知 merge** | 遇到代码冲突时停止;`.sf/` 状态冲突自动解决 | ## 文件布局 ``` -.gsd/ +.sf/ ├── parallel/ # Coordinator ↔ worker IPC │ ├── M002.status.json # Worker heartbeat + progress │ ├── M002.signal.json # Coordinator → worker signals @@ -269,7 +269,7 @@ Coordinator 会在 `refreshWorkerStatuses()` 中执行 stale detection,并自 │ └── M003.signal.json ├── worktrees/ # Git worktrees(每个 milestone 一个) │ ├── M002/ # M002 的隔离 checkout -│ │ ├── .gsd/ # M002 自己的状态文件 +│ │ ├── .sf/ # M002 自己的状态文件 │ │ │ ├── auto.lock │ │ │ ├── metrics.json │ │ │ └── milestones/ @@ -279,7 +279,7 @@ Coordinator 会在 `refreshWorkerStatuses()` 中执行 stale detection,并自 └── ... ``` -`.gsd/parallel/` 和 `.gsd/worktrees/` 都会被 gitignore,因为它们只是运行时协调文件,永远不会提交。 +`.sf/parallel/` 和 `.sf/worktrees/` 都会被 gitignore,因为它们只是运行时协调文件,永远不会提交。 ## 故障排查 @@ -289,22 +289,22 @@ Coordinator 会在 `refreshWorkerStatuses()` 中执行 stale detection,并自 ### “No milestones are eligible for parallel execution” -说明所有 milestones 要么已完成,要么被依赖阻塞。可通过 `/gsd queue` 查看 milestone 状态和依赖链。 +说明所有 milestones 要么已完成,要么被依赖阻塞。可通过 `/sf queue` 查看 milestone 状态和依赖链。 ### Worker 崩溃后如何恢复 Workers 会自动把状态持久化到磁盘。如果某个 worker 进程死亡,coordinator 会通过 heartbeat 超时检测到死掉的 PID,并把该 worker 标记为 crashed。重启后,worker 会从磁盘状态继续:崩溃恢复、worktree 重入和 completed-unit 跟踪都会延续之前的状态。 -1. 执行 `/gsd doctor --fix` 清理 stale sessions -2. 执行 `/gsd parallel status` 查看当前状态 -3. 重新执行 `/gsd parallel start`,为剩余 milestones 启动新的 workers +1. 执行 `/sf doctor --fix` 清理 stale sessions +2. 执行 `/sf parallel status` 查看当前状态 +3. 重新执行 `/sf parallel start`,为剩余 milestones 启动新的 workers ### 并行执行完成后发生 merge 冲突 -1. 执行 `/gsd parallel merge` 查看哪些 milestones 存在冲突 -2. 在 `.gsd/worktrees//` 对应的 worktree 中手动解决冲突 -3. 执行 `/gsd parallel merge ` 重试 +1. 执行 `/sf parallel merge` 查看哪些 milestones 存在冲突 +2. 在 `.sf/worktrees//` 对应的 worktree 中手动解决冲突 +3. 执行 `/sf parallel merge ` 重试 ### Workers 看起来卡住了 -先检查是否触达了预算上限:`/gsd parallel status` 会显示每个 worker 的成本。继续执行的话,提升 `parallel.budget_ceiling` 或直接移除它。 +先检查是否触达了预算上限:`/sf parallel status` 会显示每个 worker 的成本。继续执行的话,提升 `parallel.budget_ceiling` 或直接移除它。 diff --git a/docs/zh-CN/user-docs/providers.md b/docs/zh-CN/user-docs/providers.md index 22c38ae6c..1f266d35b 100644 --- a/docs/zh-CN/user-docs/providers.md +++ b/docs/zh-CN/user-docs/providers.md @@ -1,6 +1,6 @@ # Provider 设置指南 -这是一份覆盖 SF 所有受支持 LLM providers 的分步配置指南。如果你已经运行过 onboarding 向导(`gsd config`)并选择了 provider,很可能已经配置完成,可以在会话中用 `/model` 检查。 +这是一份覆盖 SF 所有受支持 LLM providers 的分步配置指南。如果你已经运行过 onboarding 向导(`sf config`)并选择了 provider,很可能已经配置完成,可以在会话中用 `/model` 检查。 ## 目录 @@ -64,7 +64,7 @@ export ANTHROPIC_API_KEY="sk-ant-..." ``` -或者运行 `gsd config`,在提示时粘贴 key。 +或者运行 `sf config`,在提示时粘贴 key。 **获取 key:** [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) @@ -76,7 +76,7 @@ export ANTHROPIC_API_KEY="sk-ant-..." # 安装 Claude Code CLI(见 https://docs.anthropic.com/en/docs/claude-code) claude # 按提示登录,然后启动 SF -gsd +sf ``` SF 会检测你本地的 Claude Code 安装,并把它作为已认证的 Anthropic surface 使用。这是 Anthropic 订阅用户符合 TOS 的方式,SF 不会直接处理你的订阅凭据。 @@ -94,10 +94,10 @@ SF 会检测你本地的 Claude Code 安装,并把它作为已认证的 Anthro 你也可以在 SF 会话中手动触发: ```bash -/gsd mcp init +/sf mcp init ``` -这会在项目的 `.mcp.json` 中写入(或更新)`gsd-workflow` 条目。Claude Code 会在下一次启动会话时自动发现这个文件。 +这会在项目的 `.mcp.json` 中写入(或更新)`sf-workflow` 条目。Claude Code 会在下一次启动会话时自动发现这个文件。 **手动配置** @@ -106,24 +106,24 @@ SF 会检测你本地的 Claude Code 安装,并把它作为已认证的 Anthro ```json { "mcpServers": { - "gsd": { + "sf": { "command": "npx", - "args": ["gsd-mcp-server"], + "args": ["sf-mcp-server"], "env": { - "SF_CLI_PATH": "/path/to/gsd" + "SF_CLI_PATH": "/path/to/sf" } } } } ``` -如果 `gsd-mcp-server` 已经全局安装: +如果 `sf-mcp-server` 已经全局安装: ```json { "mcpServers": { - "gsd": { - "command": "gsd-mcp-server" + "sf": { + "command": "sf-mcp-server" } } } @@ -140,7 +140,7 @@ MCP server 会暴露 SF 的完整 workflow 工具面:milestone planning、task 在 SF 会话里检查 MCP server 是否可达: ```bash -/gsd mcp status +/sf mcp status ``` @@ -150,7 +150,7 @@ MCP server 会暴露 SF 的完整 workflow 工具面:milestone planning、task export OPENAI_API_KEY="sk-..." ``` -或者运行 `gsd config`,选择 “Paste an API key” 然后选择 “OpenAI”。 +或者运行 `sf config`,选择 “Paste an API key” 然后选择 “OpenAI”。 **获取 key:** [platform.openai.com/api-keys](https://platform.openai.com/api-keys) @@ -178,7 +178,7 @@ OpenRouter 通过单个 API key 聚合了多个 providers 的 200+ models。 export OPENROUTER_API_KEY="sk-or-..." ``` -或者运行 `gsd config`,选择 “Paste an API key” 然后选择 “OpenRouter”。 +或者运行 `sf config`,选择 “Paste an API key” 然后选择 “OpenRouter”。 **第 3 步:切换到 OpenRouter model** @@ -186,7 +186,7 @@ export OPENROUTER_API_KEY="sk-or-..." **可选:通过 `models.json` 添加自定义 OpenRouter models** -如果你想使用不在内置列表中的 model,可把它写进 `~/.gsd/agent/models.json`: +如果你想使用不在内置列表中的 model,可把它写进 `~/.sf/agent/models.json`: ```json { @@ -268,7 +268,7 @@ export MISTRAL_API_KEY="..." 使用 OAuth,通过浏览器登录: ```bash -gsd config +sf config # 选择 "Sign in with your browser" → "GitHub Copilot" ``` @@ -320,7 +320,7 @@ export AZURE_OPENAI_API_KEY="..." 本地 providers 运行在你的机器上。因为 SF 需要知道 endpoint URL 和可用 models,所以它们都要求配置 `models.json`。 -**配置文件位置:** `~/.gsd/agent/models.json` +**配置文件位置:** `~/.sf/agent/models.json` 每次打开 `/model` 时,这个文件都会自动重新加载,无需重启。 @@ -344,7 +344,7 @@ ollama pull llama3.1:8b ollama pull qwen2.5-coder:7b ``` -**第 3 步:创建 `~/.gsd/agent/models.json`** +**第 3 步:创建 `~/.sf/agent/models.json`** ```json { @@ -389,7 +389,7 @@ ollama pull qwen2.5-coder:7b 在 LM Studio 中进入 “Local Server” 标签页,加载一个 model,然后点击 “Start Server”。默认端口为 1234。 -**第 3 步:创建 `~/.gsd/agent/models.json`** +**第 3 步:创建 `~/.sf/agent/models.json`** ```json { @@ -486,12 +486,12 @@ model `id` 必须与 `vllm serve` 启动时传入的 `--model` 参数完全一 **最快路径:使用 onboarding 向导** ```bash -gsd config +sf config # 选择 "Paste an API key" → "Custom (OpenAI-compatible)" # 输入:base URL、API key、model ID ``` -这会自动帮你写好 `~/.gsd/agent/models.json`。 +这会自动帮你写好 `~/.sf/agent/models.json`。 **手动配置:** @@ -562,7 +562,7 @@ gsd config **原因:** key 虽然设在 shell 中,但 SF 看不到。 -**解决:** 确认你是在同一个终端里 `export` 了该环境变量并运行 `gsd`。或者直接用 `gsd config` 把 key 保存进 `~/.gsd/agent/auth.json`,这样就能跨会话持久化。 +**解决:** 确认你是在同一个终端里 `export` 了该环境变量并运行 `sf`。或者直接用 `sf config` 把 key 保存进 `~/.sf/agent/auth.json`,这样就能跨会话持久化。 ### OpenRouter models 没出现在 `/model` @@ -572,7 +572,7 @@ gsd config ```bash export OPENROUTER_API_KEY="sk-or-..." -gsd +sf ``` ### Ollama 返回空响应 @@ -653,7 +653,7 @@ ollama pull llama3.1:8b 1. **启动 SF:** ```bash - gsd + sf ``` 2. **检查可用 models:** @@ -671,7 +671,7 @@ ollama pull llama3.1:8b 如果 model 没有出现,请检查: - 当前 shell 中是否设置了对应环境变量 -- `models.json` 是否是合法 JSON(可执行 `cat ~/.gsd/agent/models.json | python3 -m json.tool`) +- `models.json` 是否是合法 JSON(可执行 `cat ~/.sf/agent/models.json | python3 -m json.tool`) - 本地 providers 的 server 是否已经运行 -如果还需要更多帮助,请查看 [故障排查](./troubleshooting.md),或者在会话中运行 `/gsd doctor`。 +如果还需要更多帮助,请查看 [故障排查](./troubleshooting.md),或者在会话中运行 `/sf doctor`。 diff --git a/docs/zh-CN/user-docs/remote-questions.md b/docs/zh-CN/user-docs/remote-questions.md index ac2f93728..72df20559 100644 --- a/docs/zh-CN/user-docs/remote-questions.md +++ b/docs/zh-CN/user-docs/remote-questions.md @@ -7,7 +7,7 @@ ### Discord ``` -/gsd remote discord +/sf remote discord ``` 配置向导会: @@ -17,7 +17,7 @@ 3. 列出 bot 当前加入的服务器(或让你选择) 4. 列出所选服务器中的文本频道 5. 发送一条测试消息以确认权限 -6. 把配置保存到 `~/.gsd/PREFERENCES.md` +6. 把配置保存到 `~/.sf/PREFERENCES.md` **Bot 要求:** @@ -32,7 +32,7 @@ ### Slack ``` -/gsd remote slack +/sf remote slack ``` 配置向导会: @@ -52,7 +52,7 @@ ### Telegram ``` -/gsd remote telegram +/sf remote telegram ``` 配置向导会: @@ -71,7 +71,7 @@ ## 配置 -远程提问配置保存在 `~/.gsd/PREFERENCES.md`: +远程提问配置保存在 `~/.sf/PREFERENCES.md`: ```yaml remote_questions: @@ -113,11 +113,11 @@ remote_questions: | 命令 | 说明 | |------|------| -| `/gsd remote` | 显示远程提问菜单和当前状态 | -| `/gsd remote slack` | 配置 Slack 集成 | -| `/gsd remote discord` | 配置 Discord 集成 | -| `/gsd remote status` | 显示当前配置和最近一次提示状态 | -| `/gsd remote disconnect` | 移除远程提问配置 | +| `/sf remote` | 显示远程提问菜单和当前状态 | +| `/sf remote slack` | 配置 Slack 集成 | +| `/sf remote discord` | 配置 Discord 集成 | +| `/sf remote status` | 显示当前配置和最近一次提示状态 | +| `/sf remote disconnect` | 移除远程提问配置 | ## Discord 与 Slack 功能对比 diff --git a/docs/zh-CN/user-docs/skills.md b/docs/zh-CN/user-docs/skills.md index a32733123..51485003c 100644 --- a/docs/zh-CN/user-docs/skills.md +++ b/docs/zh-CN/user-docs/skills.md @@ -15,7 +15,7 @@ SF 会按优先级顺序从两个位置读取技能: 如果出现同名技能,全局技能优先于项目技能。 -> **从 `~/.gsd/agent/skills/` 迁移:** 升级后首次启动时,SF 会自动把旧版 `~/.gsd/agent/skills/` 中的技能复制到 `~/.agents/skills/`。旧目录会保留,以兼容旧流程。 +> **从 `~/.sf/agent/skills/` 迁移:** 升级后首次启动时,SF 会自动把旧版 `~/.sf/agent/skills/` 中的技能复制到 `~/.agents/skills/`。旧目录会保留,以兼容旧流程。 ## 安装技能 @@ -40,9 +40,9 @@ npx skills update ### 入门技能目录 -在执行 `gsd init` 时,SF 会检测项目技术栈并推荐合适的技能包。对于 brownfield 项目,检测是自动的;对于 greenfield 项目,则由用户选择技术栈。 +在执行 `sf init` 时,SF 会检测项目技术栈并推荐合适的技能包。对于 brownfield 项目,检测是自动的;对于 greenfield 项目,则由用户选择技术栈。 -这个精选目录维护在 `src/resources/extensions/gsd/skill-catalog.ts`。每一条目都会把一个技术栈映射到一个 skills.sh 仓库,以及其中的具体技能名称。 +这个精选目录维护在 `src/resources/extensions/sf/skill-catalog.ts`。每一条目都会把一个技术栈映射到一个 skills.sh 仓库,以及其中的具体技能名称。 #### 可用技能包 @@ -78,7 +78,7 @@ npx skills update ### 维护目录 -技能目录定义位于 [`src/resources/extensions/gsd/skill-catalog.ts`](../../../src/resources/extensions/gsd/skill-catalog.ts)。新增或更新一个技能包时: +技能目录定义位于 [`src/resources/extensions/sf/skill-catalog.ts`](../../../src/resources/extensions/sf/skill-catalog.ts)。新增或更新一个技能包时: 1. 在 `SKILL_CATALOG` 数组中新增一个 `SkillPack` 条目,包含 `repo`、`skills` 和匹配条件 2. 基于语言检测做匹配时,使用 `matchLanguages`(取值来自 `detection.ts` 中的 `LANGUAGE_MAP`) @@ -161,13 +161,13 @@ SF 会跨自动模式会话跟踪技能表现,并提供健康度数据,帮 ### 技能健康度面板 -通过 `/gsd skill-health` 查看技能表现: +通过 `/sf skill-health` 查看技能表现: ``` -/gsd skill-health # 总览表:名称、使用次数、成功率、token、趋势、最近使用时间 -/gsd skill-health rust-core # 查看单个技能的详细信息 -/gsd skill-health --stale 30 # 查看 30+ 天未使用的技能 -/gsd skill-health --declining # 查看成功率在下降的技能 +/sf skill-health # 总览表:名称、使用次数、成功率、token、趋势、最近使用时间 +/sf skill-health rust-core # 查看单个技能的详细信息 +/sf skill-health --stale 30 # 查看 30+ 天未使用的技能 +/sf skill-health --declining # 查看成功率在下降的技能 ``` 该面板会标出可能需要关注的技能: @@ -190,6 +190,6 @@ skill_staleness_days: 60 # 默认 60;设为 0 表示关闭 ### Heal-Skill(单元后分析) -如果把它配置为 post-unit hook,SF 可以分析 agent 在执行中是否偏离了某个技能的指令。如果检测到明显漂移(例如 API 模式过时、指导错误),它会把建议修复写到 `.gsd/skill-review-queue.md`,供人工审核。 +如果把它配置为 post-unit hook,SF 可以分析 agent 在执行中是否偏离了某个技能的指令。如果检测到明显漂移(例如 API 模式过时、指导错误),它会把建议修复写到 `.sf/skill-review-queue.md`,供人工审核。 一个关键设计原则是:技能**永远不会被自动修改**。研究表明,人工策展的技能明显优于自动生成技能,因此保留人工审核是必要的。 diff --git a/docs/zh-CN/user-docs/token-optimization.md b/docs/zh-CN/user-docs/token-optimization.md index 551319061..0d5d604f8 100644 --- a/docs/zh-CN/user-docs/token-optimization.md +++ b/docs/zh-CN/user-docs/token-optimization.md @@ -168,7 +168,7 @@ Tasks 会通过分析 task plan 来分类: ## 自适应学习(Routing History) -SF 会随着时间推移记录每个 tier 分配的成功 / 失败情况,并据此调整未来的分类。它默认自动生效,并持久化在 `.gsd/routing-history.json` 中。 +SF 会随着时间推移记录每个 tier 分配的成功 / 失败情况,并据此调整未来的分类。它默认自动生效,并持久化在 `.sf/routing-history.json` 中。 ### 工作方式 @@ -179,12 +179,12 @@ SF 会随着时间推移记录每个 tier 分配的成功 / 失败情况,并 ### 用户反馈 -你可以通过 `/gsd rate` 为最近完成的工作单元提交反馈: +你可以通过 `/sf rate` 为最近完成的工作单元提交反馈: ``` -/gsd rate over # model 太强了,下次更倾向便宜一点 -/gsd rate ok # model 选得合适,不调整 -/gsd rate under # model 太弱了,下次更倾向强一点 +/sf rate over # model 太强了,下次更倾向便宜一点 +/sf rate ok # model 选得合适,不调整 +/sf rate under # model 太弱了,下次更倾向强一点 ``` 这些反馈的权重是自动结果的 2 倍。要求 dynamic routing 已启用(最近完成的单元必须带有 tier 数据)。 @@ -193,7 +193,7 @@ SF 会随着时间推移记录每个 tier 分配的成功 / 失败情况,并 ```bash # Routing history 按项目存储 -.gsd/routing-history.json +.sf/routing-history.json # 清空历史以重置自适应学习 # (通过 routing-history 模块 API 完成) @@ -312,7 +312,7 @@ context_management: *引入于 v2.59.0* -当自动模式在 phases 之间切换(research → planning → execution)时,系统会把结构化 JSON anchors 写到 `.gsd/milestones//anchors/.json`。下游 prompt builders 会自动注入这些 anchors,让下一阶段继承前一阶段的意图、决策、阻塞点和下一步,而不必重新从 artifact 文件里推断。 +当自动模式在 phases 之间切换(research → planning → execution)时,系统会把结构化 JSON anchors 写到 `.sf/milestones//anchors/.json`。下游 prompt builders 会自动注入这些 anchors,让下一阶段继承前一阶段的意图、决策、阻塞点和下一步,而不必重新从 artifact 文件里推断。 这能减少上下文漂移,也就是企业级 agent 失败案例中最常见的一类问题:agent 在 phase 边界上丢失了之前的决策脉络。 diff --git a/docs/zh-CN/user-docs/troubleshooting.md b/docs/zh-CN/user-docs/troubleshooting.md index a06324b9e..ca9bc2028 100644 --- a/docs/zh-CN/user-docs/troubleshooting.md +++ b/docs/zh-CN/user-docs/troubleshooting.md @@ -1,11 +1,11 @@ # 故障排查 -## `/gsd doctor` +## `/sf doctor` -内置诊断工具会校验 `.gsd/` 的完整性: +内置诊断工具会校验 `.sf/` 的完整性: ``` -/gsd doctor +/sf doctor ``` 它会检查: @@ -27,13 +27,13 @@ - 崩溃后的缓存过期:内存中的文件列表没有反映新产物 - LLM 没有生成预期的 artifact 文件 -**解决:** 先运行 `/gsd doctor` 修复状态,然后执行 `/gsd auto` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。 +**解决:** 先运行 `/sf doctor` 修复状态,然后执行 `/sf auto` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。 ### 自动模式因 “Loop detected” 停止 **原因:** 同一个单元连续两次没有生成预期 artifact。 -**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/gsd auto` 恢复。 +**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/sf auto` 恢复。 ### Worktree 中出现了错误文件 @@ -43,9 +43,9 @@ **解决:** 该问题已在 v2.14+ 修复。如果你仍在旧版本,请更新。现在 dispatch prompt 已包含明确的工作目录指令。 -### 安装后出现 `command not found: gsd` +### 安装后出现 `command not found: sf` -**症状:** `npm install -g sf-run` 成功,但系统找不到 `gsd`。 +**症状:** `npm install -g sf-run` 成功,但系统找不到 `sf`。 **原因:** npm 的全局 bin 目录没有加入 shell 的 `$PATH`。 @@ -61,13 +61,13 @@ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc source ~/.zshrc ``` -**临时方案:** 直接执行 `npx sf-run`,或使用 `$(npm prefix -g)/bin/gsd`。 +**临时方案:** 直接执行 `npx sf-run`,或使用 `$(npm prefix -g)/bin/sf`。 **常见原因:** - **Homebrew Node**:理论上 `/opt/homebrew/bin` 应该在 PATH 里,但如果 shell profile 没有初始化 Homebrew,就可能缺失 - **版本管理器(nvm、fnm、mise)**:全局 bin 路径是按版本区分的,需确保版本管理器正确初始化 -- **oh-my-zsh**:`gitfast` 插件会把 `gsd` alias 到 `git svn dcommit`。可通过 `alias gsd` 检查,并在需要时取消 alias +- **oh-my-zsh**:`gitfast` 插件会把 `sf` alias 到 `git svn dcommit`。可通过 `alias sf` 检查,并在需要时取消 alias ### `npm install -g sf-run` 失败 @@ -99,7 +99,7 @@ models: - openrouter/minimax/minimax-m2.5 ``` -**Headless 模式:** `gsd headless auto` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。 +**Headless 模式:** `sf headless auto` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。 常见的 provider 配置问题(role 错误、streaming 错误、model ID 不匹配)见 [Provider 设置指南:常见坑点](./providers.md#common-pitfalls)。 @@ -107,30 +107,30 @@ models: **症状:** 自动模式因 “Budget ceiling reached” 暂停。 -**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/gsd auto` 恢复。 +**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/sf auto` 恢复。 ### 过期锁文件 **症状:** 自动模式无法启动,提示另一个会话正在运行。 -**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/gsd auto` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.gsd.lock/` 目录。如果自动恢复失败,可手动删除 `.gsd/auto.lock` 和 `.gsd.lock/`: +**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/sf auto` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.sf.lock/` 目录。如果自动恢复失败,可手动删除 `.sf/auto.lock` 和 `.sf.lock/`: ```bash -rm -f .gsd/auto.lock -rm -rf "$(dirname .gsd)/.gsd.lock" +rm -f .sf/auto.lock +rm -rf "$(dirname .sf)/.sf.lock" ``` ### Git merge 冲突 -**症状:** Worktree merge 在 `.gsd/` 文件上失败。 +**症状:** Worktree merge 在 `.sf/` 文件上失败。 -**解决:** SF 会自动解决 `.gsd/` 运行时文件上的冲突。对于代码文件的内容冲突,LLM 会先获得一次 fix-merge 会话进行自动修复;若失败,则需要手动解决。 +**解决:** SF 会自动解决 `.sf/` 运行时文件上的冲突。对于代码文件的内容冲突,LLM 会先获得一次 fix-merge 会话进行自动修复;若失败,则需要手动解决。 ### Pre-dispatch 提示 milestone integration branch 已不存在 -**症状:** 自动模式或 `/gsd doctor` 报告某个 milestone 记录的 integration branch 已经不在 git 中。 +**症状:** 自动模式或 `/sf doctor` 报告某个 milestone 记录的 integration branch 已经不在 git 中。 -**这意味着什么:** 该 milestone 的 `.gsd/milestones//-META.json` 里仍然记录着启动时的 branch,但该 branch 之后被重命名或删除了。 +**这意味着什么:** 该 milestone 的 `.sf/milestones//-META.json` 里仍然记录着启动时的 branch,但该 branch 之后被重命名或删除了。 **当前行为:** @@ -138,17 +138,17 @@ rm -rf "$(dirname .gsd)/.gsd.lock" - 安全回退的顺序是: - 显式配置且存在的 `git.main_branch` - 仓库自动检测到的默认 integration branch(例如 `main` 或 `master`) -- 在这种情况下,`/gsd doctor` 会给出 warning,而 `/gsd doctor fix` 会把过期的 metadata 改写为当前有效 branch +- 在这种情况下,`/sf doctor` 会给出 warning,而 `/sf doctor fix` 会把过期的 metadata 改写为当前有效 branch - 如果无法确定安全回退 branch,SF 仍会阻止继续运行 **解决:** -- 先执行 `/gsd doctor fix`,在安全回退很明显时自动改写过期 metadata +- 先执行 `/sf doctor fix`,在安全回退很明显时自动改写过期 metadata - 如果 SF 仍然阻塞,则请重新创建缺失 branch,或更新 git 偏好设置,让 `git.main_branch` 指向一个真实存在的 branch -### 写 `.gsd/` 文件时出现瞬时 `EBUSY` / `EPERM` / `EACCES` +### 写 `.sf/` 文件时出现瞬时 `EBUSY` / `EPERM` / `EACCES` -**症状:** 在 Windows 上,自动模式或 doctor 在更新 `.gsd/` 文件时偶发 `EBUSY`、`EPERM` 或 `EACCES`。 +**症状:** 在 Windows 上,自动模式或 doctor 在更新 `.sf/` 文件时偶发 `EBUSY`、`EPERM` 或 `EACCES`。 **原因:** 杀毒软件、索引器、编辑器或文件监视器可能会在 SF 执行原子 rename 的瞬间,短暂锁住目标文件或临时文件。 @@ -158,11 +158,11 @@ rm -rf "$(dirname .gsd)/.gsd.lock" - 重新执行操作;大多数瞬时锁竞争会很快自行解除 - 如果错误持续,关闭可能占用该文件的工具后再试 -- 如果反复失败,运行 `/gsd doctor`,确认仓库状态依旧健康,并记录具体路径与错误码 +- 如果反复失败,运行 `/sf doctor`,确认仓库状态依旧健康,并记录具体路径与错误码 ### Node v24 Web 启动失败 -**症状:** 在 Node v24 上执行 `gsd --web` 时,报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。 +**症状:** 在 Node v24 上执行 `sf --web` 时,报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。 **原因:** Node v24 修改了对 `node_modules` 的 type stripping 行为,导致 Next.js Web 构建失败。 @@ -170,7 +170,7 @@ rm -rf "$(dirname .gsd)/.gsd.lock" ### 孤儿 Web server 进程 -**症状:** `gsd --web` 因端口 3000 已被占用而失败,但实际上并没有运行中的 SF 会话。 +**症状:** `sf --web` 因端口 3000 已被占用而失败,但实际上并没有运行中的 SF 会话。 **原因:** 上一次 Web server 退出时未能清理进程。 @@ -200,13 +200,13 @@ rm -rf "$(dirname .gsd)/.gsd.lock" **常见原因:** -- 当前项目里不存在 `.mcp.json` 或 `.gsd/mcp.json` +- 当前项目里不存在 `.mcp.json` 或 `.sf/mcp.json` - 配置文件不是合法 JSON - 你是在另一个项目目录中配置的 server,但当前启动 SF 的目录不同 **解决:** -- 把 server 配置加到 `.mcp.json` 或 `.gsd/mcp.json` +- 把 server 配置加到 `.mcp.json` 或 `.sf/mcp.json` - 确认文件能被正常解析为 JSON - 重新执行 `mcp_servers(refresh=true)` @@ -275,11 +275,11 @@ rm -rf "$(dirname .gsd)/.gsd.lock" - 把所需环境变量写进 MCP 配置的 `env` 块 - 有必要时,在 server 定义里显式设置 `cwd` -### Session lock 被另一个终端中的 `/gsd` 抢走 +### Session lock 被另一个终端中的 `/sf` 抢走 -**症状:** 在第二个终端运行 `/gsd`(step mode)时,正在运行的自动模式会话失去了锁。 +**症状:** 在第二个终端运行 `/sf`(step mode)时,正在运行的自动模式会话失去了锁。 -**解决:** 已在 v2.36.0 修复。现在裸 `/gsd` 不会再从运行中的自动模式会话手里抢 session lock。升级到最新版本。 +**解决:** 已在 v2.36.0 修复。现在裸 `/sf` 不会再从运行中的自动模式会话手里抢 session lock。升级到最新版本。 ### Worktree 中的提交落到了 main,而不是 `milestone/` 分支 @@ -300,34 +300,34 @@ rm -rf "$(dirname .gsd)/.gsd.lock" ### 重置自动模式状态 ```bash -rm .gsd/auto.lock -rm .gsd/completed-units.json +rm .sf/auto.lock +rm .sf/completed-units.json ``` -然后执行 `/gsd auto`,从当前磁盘状态重新开始。 +然后执行 `/sf auto`,从当前磁盘状态重新开始。 ### 重置路由历史 如果自适应模型路由给出了糟糕的结果,可以清空路由历史: ```bash -rm .gsd/routing-history.json +rm .sf/routing-history.json ``` ### 完整重建状态 ``` -/gsd doctor +/sf doctor ``` Doctor 会从磁盘上的 plan 和 roadmap 文件重建 `STATE.md`,并修复检测到的不一致项。 ## 获取帮助 -- **GitHub Issues:** [github.com/gsd-build/SF/issues](https://github.com/gsd-build/SF/issues) -- **Dashboard:** `Ctrl+Alt+G` 或 `/gsd status`,查看实时诊断信息 -- **Forensics:** `/gsd forensics`,用于对自动模式失败做结构化事后分析 -- **Session logs:** `.gsd/activity/` 中包含用于崩溃取证的 JSONL 会话转储 +- **GitHub Issues:** [github.com/sf-build/SF/issues](https://github.com/sf-build/SF/issues) +- **Dashboard:** `Ctrl+Alt+G` 或 `/sf status`,查看实时诊断信息 +- **Forensics:** `/sf forensics`,用于对自动模式失败做结构化事后分析 +- **Session logs:** `.sf/activity/` 中包含用于崩溃取证的 JSONL 会话转储 ## iTerm2 专属问题 @@ -363,7 +363,7 @@ Doctor 会从磁盘上的 plan 和 roadmap 文件重建 `STATE.md`,并修复 **症状:** `gsd_decision_save`(及其别名 `gsd_save_decision`)、`gsd_requirement_update`(及其别名 `gsd_update_requirement`)或 `gsd_summary_save`(及其别名 `gsd_save_summary`)报这个错误。 -**原因:** SQLite 数据库未初始化。这个问题会出现在 v2.29 之前的手动 `/gsd` 会话(非自动模式)中。 +**原因:** SQLite 数据库未初始化。这个问题会出现在 v2.29 之前的手动 `/sf` 会话(非自动模式)中。 **解决:** 已在 v2.29+ 修复。现在数据库会在第一次 tool call 时自动初始化。升级到最新版本。 diff --git a/docs/zh-CN/user-docs/visualizer.md b/docs/zh-CN/user-docs/visualizer.md index 6b652f37e..6d75c864b 100644 --- a/docs/zh-CN/user-docs/visualizer.md +++ b/docs/zh-CN/user-docs/visualizer.md @@ -7,7 +7,7 @@ ## 打开可视化器 ``` -/gsd visualize +/sf visualize ``` 或者配置为在 milestone 完成后自动显示: @@ -59,7 +59,7 @@ S01 ──→ S02 ──→ S04 - **按 slice**:每个 slice 的成本以及累计总额 - **按模型**:哪些模型消耗了最多预算 -数据来自 `.gsd/metrics.json`。 +数据来自 `.sf/metrics.json`。 ### 4. 时间线 @@ -89,7 +89,7 @@ S01 ──→ S02 ──→ S04 ## HTML 导出(v2.26) -如果需要在终端外部分享报告,可以使用 `/gsd export --html`。它会在 `.gsd/reports/` 中生成一个自包含的 HTML 文件,包含与 TUI 可视化器相同的数据:进度树、依赖图(SVG DAG)、成本 / Token 柱状图、执行时间线、变更日志和知识库。所有 CSS 和 JS 都会内联,无外部依赖,也可以在任意浏览器中打印为 PDF。 +如果需要在终端外部分享报告,可以使用 `/sf export --html`。它会在 `.sf/reports/` 中生成一个自包含的 HTML 文件,包含与 TUI 可视化器相同的数据:进度树、依赖图(SVG DAG)、成本 / Token 柱状图、执行时间线、变更日志和知识库。所有 CSS 和 JS 都会内联,无外部依赖,也可以在任意浏览器中打印为 PDF。 自动生成的 `index.html` 会集中列出所有报告,并显示跨 milestones 的推进指标。 diff --git a/docs/zh-CN/user-docs/web-interface.md b/docs/zh-CN/user-docs/web-interface.md index ac42249da..7a1357580 100644 --- a/docs/zh-CN/user-docs/web-interface.md +++ b/docs/zh-CN/user-docs/web-interface.md @@ -7,7 +7,7 @@ SF 提供了基于浏览器的 Web 界面,用于项目管理、实时进度监 ## 快速开始 ```bash -gsd --web +sf --web ``` 这会启动一个本地 Web 服务器,并在默认浏览器中打开 SF 仪表板。 @@ -15,7 +15,7 @@ gsd --web ### CLI 参数(v2.42.0) ```bash -gsd --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" +sf --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" ``` | 参数 | 默认值 | 说明 | diff --git a/docs/zh-CN/user-docs/working-in-teams.md b/docs/zh-CN/user-docs/working-in-teams.md index a374af7bf..0e51f9e12 100644 --- a/docs/zh-CN/user-docs/working-in-teams.md +++ b/docs/zh-CN/user-docs/working-in-teams.md @@ -9,7 +9,7 @@ SF 支持多人并行工作流,让多个开发者可以同时在同一个仓 为团队使用配置 SF 的最简单方法,是在项目偏好中设置 `mode: team`。这会一次性开启唯一 milestone ID、推送分支和预合并检查: ```yaml -# .gsd/PREFERENCES.md(项目级,提交到 git) +# .sf/PREFERENCES.md(项目级,提交到 git) --- version: 1 mode: team @@ -26,24 +26,24 @@ mode: team ```bash # ── SF:运行时 / 临时文件(按开发者、按会话隔离)────── -.gsd/auto.lock -.gsd/completed-units.json -.gsd/STATE.md -.gsd/metrics.json -.gsd/activity/ -.gsd/runtime/ -.gsd/worktrees/ -.gsd/milestones/**/continue.md -.gsd/milestones/**/*-CONTINUE.md +.sf/auto.lock +.sf/completed-units.json +.sf/STATE.md +.sf/metrics.json +.sf/activity/ +.sf/runtime/ +.sf/worktrees/ +.sf/milestones/**/continue.md +.sf/milestones/**/*-CONTINUE.md ``` **会共享的内容**(提交到 git): -- `.gsd/PREFERENCES.md`:项目偏好 -- `.gsd/PROJECT.md`:持续维护的项目描述 -- `.gsd/REQUIREMENTS.md`:需求契约 -- `.gsd/DECISIONS.md`:架构决策 -- `.gsd/milestones/`:roadmaps、plans、summaries 和 research +- `.sf/PREFERENCES.md`:项目偏好 +- `.sf/PROJECT.md`:持续维护的项目描述 +- `.sf/REQUIREMENTS.md`:需求契约 +- `.sf/DECISIONS.md`:架构决策 +- `.sf/milestones/`:roadmaps、plans、summaries 和 research **仅保留本地的内容**(gitignore): @@ -52,7 +52,7 @@ mode: team ### 3. 提交偏好设置 ```bash -git add .gsd/PREFERENCES.md +git add .sf/PREFERENCES.md git commit -m "chore: enable SF team workflow" ``` @@ -65,21 +65,21 @@ git: commit_docs: false ``` -这会把整个 `.gsd/` 加入 `.gitignore`,让所有产物都保留在本地。这样使用 SF 的开发者仍然能获得结构化规划的好处,而不会影响不使用 SF 的同事。 +这会把整个 `.sf/` 加入 `.gitignore`,让所有产物都保留在本地。这样使用 SF 的开发者仍然能获得结构化规划的好处,而不会影响不使用 SF 的同事。 ## 迁移现有项目 -如果你当前项目里对 `.gsd/` 做了整目录忽略: +如果你当前项目里对 `.sf/` 做了整目录忽略: 1. 确保当前没有进行中的 milestones(工作区状态干净) 2. 按上面的选择性规则更新 `.gitignore` -3. 在 `.gsd/PREFERENCES.md` 中添加 `unique_milestone_ids: true` +3. 在 `.sf/PREFERENCES.md` 中添加 `unique_milestone_ids: true` 4. 如有需要,重命名现有 milestones 以使用唯一 ID: ``` I have turned on unique milestone ids, please update all old milestone ids to use this new format e.g. M001-abc123 where abc123 is a random 6 char lowercase alpha numeric string. Update all references in all - .gsd file contents, file names and directory names. Validate your work + .sf file contents, file names and directory names. Validate your work once done to ensure referential integrity. ``` 5. 提交修改 @@ -88,7 +88,7 @@ git: 多个开发者可以同时对不同 milestones 运行自动模式。每个开发者都会: -- 获得自己的 worktree(`.gsd/worktrees//`,已加入 gitignore) +- 获得自己的 worktree(`.sf/worktrees//`,已加入 gitignore) - 在独立的 `milestone/` 分支上工作 - 独立地 squash merge 回主分支 diff --git a/gitbook/README.md b/gitbook/README.md index 4ac813b65..2582b9ecb 100644 --- a/gitbook/README.md +++ b/gitbook/README.md @@ -22,7 +22,7 @@ You can stay hands-on with **step mode** (reviewing each step) or let SF run aut ## Key Features -- **Autonomous execution** — `/gsd auto` runs research, planning, coding, testing, and committing without intervention +- **Autonomous execution** — `/sf auto` runs research, planning, coding, testing, and committing without intervention - **20+ LLM providers** — Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, local models, and more - **Git isolation** — Each milestone works in its own worktree branch, merged cleanly when done - **Cost tracking** — Real-time token usage, budget ceilings, and automatic model downgrading @@ -41,10 +41,10 @@ You can stay hands-on with **step mode** (reviewing each step) or let SF run aut npm install -g sf-run # Launch -gsd +sf # Start autonomous mode -/gsd auto +/sf auto ``` See [Installation](getting-started/installation.md) for detailed setup instructions. @@ -53,8 +53,8 @@ See [Installation](getting-started/installation.md) for detailed setup instructi | Mode | Command | Best For | |------|---------|----------| -| **Step** | `/gsd` | Staying in the loop, reviewing each step | -| **Auto** | `/gsd auto` | Walking away, overnight builds, batch work | +| **Step** | `/sf` | Staying in the loop, reviewing each step | +| **Auto** | `/sf auto` | Walking away, overnight builds, batch work | The recommended workflow: run auto mode in one terminal, steer from another. See [Step Mode](core-concepts/step-mode.md) and [Auto Mode](core-concepts/auto-mode.md). diff --git a/gitbook/configuration/custom-models.md b/gitbook/configuration/custom-models.md index a7ebd6b3a..10867f385 100644 --- a/gitbook/configuration/custom-models.md +++ b/gitbook/configuration/custom-models.md @@ -1,11 +1,11 @@ # Custom Models -Define custom models and providers in `~/.gsd/agent/models.json`. This lets you add models not in the default registry — self-hosted endpoints, fine-tuned models, proxies, or new provider releases. +Define custom models and providers in `~/.sf/agent/models.json`. This lets you add models not in the default registry — self-hosted endpoints, fine-tuned models, proxies, or new provider releases. ## File Location SF looks for models.json at: -1. `~/.gsd/agent/models.json` (primary) +1. `~/.sf/agent/models.json` (primary) 2. `~/.pi/agent/models.json` (fallback) The file reloads each time you open `/model` — no restart needed. @@ -128,4 +128,4 @@ For providers not built into SF, community extensions add full provider support: | Extension | Provider | Install | |-----------|----------|---------| -| `pi-dashscope` | Alibaba DashScope (Qwen3, GLM-5, etc.) | `gsd install npm:pi-dashscope` | +| `pi-dashscope` | Alibaba DashScope (Qwen3, GLM-5, etc.) | `sf install npm:pi-dashscope` | diff --git a/gitbook/configuration/git-settings.md b/gitbook/configuration/git-settings.md index 9dc927f32..6ceb1148c 100644 --- a/gitbook/configuration/git-settings.md +++ b/gitbook/configuration/git-settings.md @@ -8,7 +8,7 @@ SF supports three isolation modes, configured via `git.isolation` in preferences | Mode | Working Directory | Branch | Best For | |------|-------------------|--------|----------| -| `worktree` (default) | `.gsd/worktrees//` | `milestone/` | Most projects — full isolation | +| `worktree` (default) | `.sf/worktrees//` | `milestone/` | Most projects — full isolation | | `branch` | Project root | `milestone/` | Submodule-heavy repos | | `none` | Project root | Current branch | Hot-reload workflows | @@ -69,7 +69,7 @@ git: main_branch: main # primary branch name merge_strategy: squash # "squash" or "merge" isolation: worktree # "worktree", "branch", or "none" - commit_docs: true # commit .gsd/ artifacts to git + commit_docs: true # commit .sf/ artifacts to git manage_gitignore: true # let SF manage .gitignore auto_pr: false # create PR on milestone completion pr_target_branch: develop # PR target branch @@ -94,7 +94,7 @@ Run a script after worktree creation (copy `.env` files, symlink assets, etc.): ```yaml git: - worktree_post_create: .gsd/hooks/post-worktree-create + worktree_post_create: .sf/hooks/post-worktree-create ``` Example hook: @@ -105,7 +105,7 @@ cp "$SOURCE_DIR/.env" "$WORKTREE_DIR/.env" ln -sf "$SOURCE_DIR/assets" "$WORKTREE_DIR/assets" ``` -## Keeping `.gsd/` Local +## Keeping `.sf/` Local For teams where only some members use SF: @@ -114,7 +114,7 @@ git: commit_docs: false ``` -This adds `.gsd/` to `.gitignore` entirely. You get structured planning without affecting teammates who don't use SF. +This adds `.sf/` to `.gitignore` entirely. You get structured planning without affecting teammates who don't use SF. ## Commit Format @@ -145,4 +145,4 @@ SF automatically recovers from common git issues: - **Stale lock files** — removes `index.lock` from crashed processes - **Orphaned worktrees** — detects and cleans up abandoned worktrees -Run `/gsd doctor` to check git health manually. +Run `/sf doctor` to check git health manually. diff --git a/gitbook/configuration/mcp-servers.md b/gitbook/configuration/mcp-servers.md index 893910708..20f18feb2 100644 --- a/gitbook/configuration/mcp-servers.md +++ b/gitbook/configuration/mcp-servers.md @@ -7,7 +7,7 @@ SF can connect to external MCP (Model Context Protocol) servers for local tools, SF reads MCP config from these project-local paths: - `.mcp.json` — repo-shared config (safe to commit) -- `.gsd/mcp.json` — local-only config (not shared) +- `.sf/mcp.json` — local-only config (not shared) If both exist, server names are merged and the first definition found wins. @@ -61,5 +61,5 @@ After adding config, verify from a SF session: - Use **absolute paths** for executables and scripts - Set required **environment variables** directly in the MCP config's `env` block -- Use `.mcp.json` for team-shared servers; `.gsd/mcp.json` for machine-local ones -- If a server depends on local paths or personal secrets, keep it in `.gsd/mcp.json` +- Use `.mcp.json` for team-shared servers; `.sf/mcp.json` for machine-local ones +- If a server depends on local paths or personal secrets, keep it in `.sf/mcp.json` diff --git a/gitbook/configuration/preferences.md b/gitbook/configuration/preferences.md index 17398d25a..56f70f911 100644 --- a/gitbook/configuration/preferences.md +++ b/gitbook/configuration/preferences.md @@ -5,17 +5,17 @@ SF preferences live in YAML frontmatter markdown files. You can configure them g ## Managing Preferences ``` -/gsd prefs # open the global preferences wizard -/gsd prefs project # open the project preferences wizard -/gsd prefs status # show current values and where they come from +/sf prefs # open the global preferences wizard +/sf prefs project # open the project preferences wizard +/sf prefs status # show current values and where they come from ``` ## Preference Files | Scope | Path | Applies To | |-------|------|-----------| -| Global | `~/.gsd/PREFERENCES.md` | All projects | -| Project | `.gsd/PREFERENCES.md` | Current project only | +| Global | `~/.sf/PREFERENCES.md` | All projects | +| Project | `.sf/PREFERENCES.md` | Current project only | **How they merge:** - **Scalar fields** (`budget_ceiling`, `token_profile`): project wins if defined @@ -219,7 +219,7 @@ custom_instructions: - "Prefer functional patterns over classes" ``` -For project-specific patterns, use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically. +For project-specific patterns, use `.sf/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically. ### `context_pause_threshold` diff --git a/gitbook/configuration/providers.md b/gitbook/configuration/providers.md index 106119424..b64fc5c93 100644 --- a/gitbook/configuration/providers.md +++ b/gitbook/configuration/providers.md @@ -1,6 +1,6 @@ # Provider Setup -Step-by-step setup instructions for every LLM provider SF supports. If you ran the onboarding wizard (`gsd config`) and picked a provider, you may already be configured — check with `/model` inside a session. +Step-by-step setup instructions for every LLM provider SF supports. If you ran the onboarding wizard (`sf config`) and picked a provider, you may already be configured — check with `/model` inside a session. ## Quick Reference @@ -30,7 +30,7 @@ Step-by-step setup instructions for every LLM provider SF supports. If you ran t **Option A — Browser sign-in (recommended):** ```bash -gsd config +sf config # Choose "Sign in with your browser" → "Anthropic (Claude)" ``` @@ -48,7 +48,7 @@ export ANTHROPIC_API_KEY="sk-ant-..." export OPENAI_API_KEY="sk-..." ``` -Or run `gsd config` and choose "Paste an API key" then "OpenAI". +Or run `sf config` and choose "Paste an API key" then "OpenAI". ### Google Gemini @@ -67,7 +67,7 @@ OpenRouter aggregates 200+ models from multiple providers behind a single API ke ``` 3. In SF, type `/model` to select an OpenRouter model (prefixed with `openrouter/`) -To add models not in the built-in list, add them to `~/.gsd/agent/models.json`. See [Custom Models](custom-models.md). +To add models not in the built-in list, add them to `~/.sf/agent/models.json`. See [Custom Models](custom-models.md). ### Groq @@ -92,7 +92,7 @@ export MISTRAL_API_KEY="..." Uses OAuth — sign in through the browser: ```bash -gsd config +sf config # Choose "Sign in with your browser" → "GitHub Copilot" ``` @@ -132,7 +132,7 @@ export AZURE_OPENAI_API_KEY="..." ## Local Providers -Local providers run on your machine. They require a `models.json` configuration file at `~/.gsd/agent/models.json` because SF needs to know the endpoint URL and available models. +Local providers run on your machine. They require a `models.json` configuration file at `~/.sf/agent/models.json` because SF needs to know the endpoint URL and available models. The file reloads each time you open `/model` — no restart needed. @@ -149,7 +149,7 @@ The file reloads each time you open `/model` — no restart needed. ollama pull llama3.1:8b ``` -3. Create `~/.gsd/agent/models.json`: +3. Create `~/.sf/agent/models.json`: ```json { "providers": { @@ -175,7 +175,7 @@ The file reloads each time you open `/model` — no restart needed. 1. Install [LM Studio](https://lmstudio.ai) 2. Go to "Local Server" tab, load a model, click "Start Server" (default port 1234) -3. Create `~/.gsd/agent/models.json`: +3. Create `~/.sf/agent/models.json`: ```json { "providers": { @@ -245,16 +245,16 @@ Any server that implements the OpenAI Chat Completions API can work with SF — **Quickest path:** ```bash -gsd config +sf config # Choose "Paste an API key" → "Custom (OpenAI-compatible)" # Enter: base URL, API key, model ID ``` -This writes `~/.gsd/agent/models.json` for you. See [Custom Models](custom-models.md) for manual setup. +This writes `~/.sf/agent/models.json` for you. See [Custom Models](custom-models.md) for manual setup. ## Verifying Your Setup -1. Launch SF: `gsd` +1. Launch SF: `sf` 2. Check available models: `/model` 3. Select your model from the picker 4. Send a test message to confirm it responds @@ -268,7 +268,7 @@ If the model doesn't appear, check: | Problem | Cause | Fix | |---------|-------|-----| -| "Authentication failed" with valid key | Key not visible to SF | Export in the same terminal, or save via `gsd config` | +| "Authentication failed" with valid key | Key not visible to SF | Export in the same terminal, or save via `sf config` | | OpenRouter models not in `/model` | No API key set | Set `OPENROUTER_API_KEY` and restart | | Ollama returns empty responses | Server not running or model not pulled | Run `ollama serve` and `ollama pull ` | | LM Studio model ID mismatch | ID doesn't match server | Check LM Studio's server tab for the exact identifier | diff --git a/gitbook/core-concepts/auto-mode.md b/gitbook/core-concepts/auto-mode.md index e890af67c..b3b27e3c9 100644 --- a/gitbook/core-concepts/auto-mode.md +++ b/gitbook/core-concepts/auto-mode.md @@ -1,14 +1,14 @@ # Auto Mode -Auto mode is SF's autonomous execution engine. Run `/gsd auto`, walk away, come back to built software with clean git history. +Auto mode is SF's autonomous execution engine. Run `/sf auto`, walk away, come back to built software with clean git history. ## Starting Auto Mode ``` -/gsd auto +/sf auto ``` -SF reads `.gsd/STATE.md`, determines the next unit of work, creates a fresh AI session with all relevant context, and lets the AI execute. When it finishes, SF reads disk state again and dispatches the next unit. This continues until the milestone is complete. +SF reads `.sf/STATE.md`, determines the next unit of work, creates a fresh AI session with all relevant context, and lets the AI execute. When it finishes, SF reads disk state again and dispatches the next unit. This continues until the milestone is complete. ## The Execution Loop @@ -35,7 +35,7 @@ Press **Escape**. The conversation is preserved. You can interact with the agent ### Resume ``` -/gsd auto +/sf auto ``` Auto mode reads disk state and picks up where it left off. @@ -43,7 +43,7 @@ Auto mode reads disk state and picks up where it left off. ### Stop ``` -/gsd stop +/sf stop ``` Stops auto mode gracefully. Can be run from a different terminal. @@ -51,7 +51,7 @@ Stops auto mode gracefully. Can be run from a different terminal. ### Steer ``` -/gsd steer +/sf steer ``` Modify plan documents during execution without stopping. Changes are picked up at the next phase boundary. @@ -59,7 +59,7 @@ Modify plan documents during execution without stopping. Changes are picked up a ### Capture Thoughts ``` -/gsd capture "add rate limiting to API endpoints" +/sf capture "add rate limiting to API endpoints" ``` Fire-and-forget thought capture. Captures are triaged automatically between tasks without pausing execution. See [Captures & Triage](../features/captures.md). @@ -82,9 +82,9 @@ In worktree mode, all commits are squash-merged to main as one clean commit when ## Crash Recovery -If a session dies, the next `/gsd auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. +If a session dies, the next `/sf auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. -In headless mode (`gsd headless auto`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution. +In headless mode (`sf headless auto`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution. ## Provider Error Recovery @@ -151,7 +151,7 @@ Every unit's token usage and cost is captured, broken down by phase, slice, and ## Dashboard -`Ctrl+Alt+G` or `/gsd status` shows real-time progress: +`Ctrl+Alt+G` or `/sf status` shows real-time progress: - Current milestone, slice, and task - Auto mode elapsed time and phase @@ -163,21 +163,21 @@ Every unit's token usage and cost is captured, broken down by phase, slice, and ## HTML Reports -After a milestone completes, SF generates a self-contained HTML report in `.gsd/reports/` with project summary, progress tree, dependency graph, cost metrics, timeline, and changelog. Generate manually with: +After a milestone completes, SF generates a self-contained HTML report in `.sf/reports/` with project summary, progress tree, dependency graph, cost metrics, timeline, and changelog. Generate manually with: ``` -/gsd export --html -/gsd export --html --all # all milestones +/sf export --html +/sf export --html --all # all milestones ``` ## Diagnostic Tools If auto mode has issues, SF provides two diagnostic tools: -- **`/gsd doctor`** — validates `.gsd/` integrity, checks referential consistency, fixes structural issues -- **`/gsd forensics`** — full post-mortem debugger with anomaly detection, unit traces, metrics analysis, and AI-guided investigation +- **`/sf doctor`** — validates `.sf/` integrity, checks referential consistency, fixes structural issues +- **`/sf forensics`** — full post-mortem debugger with anomaly detection, unit traces, metrics analysis, and AI-guided investigation ``` -/gsd doctor -/gsd forensics [optional problem description] +/sf doctor +/sf forensics [optional problem description] ``` diff --git a/gitbook/core-concepts/project-structure.md b/gitbook/core-concepts/project-structure.md index 30a8023c2..635394743 100644 --- a/gitbook/core-concepts/project-structure.md +++ b/gitbook/core-concepts/project-structure.md @@ -37,12 +37,12 @@ Examples: - "Implement JWT middleware" - "Build the login form component" -## The `.gsd/` Directory +## The `.sf/` Directory -All project state lives on disk in a `.gsd/` directory at your project root: +All project state lives on disk in a `.sf/` directory at your project root: ``` -.gsd/ +.sf/ PROJECT.md — living description of what the project is REQUIREMENTS.md — requirement contract (active/validated/deferred) DECISIONS.md — append-only architectural decisions log @@ -96,9 +96,9 @@ After all slices complete, a **milestone validation** gate checks that success c SF maintains a knowledge base that persists across sessions. Add rules, patterns, or lessons: ``` -/gsd knowledge rule "Always use parameterized queries for database access" -/gsd knowledge pattern "Service classes go in src/services/" -/gsd knowledge lesson "The OAuth flow requires the redirect URL to match exactly" +/sf knowledge rule "Always use parameterized queries for database access" +/sf knowledge pattern "Service classes go in src/services/" +/sf knowledge lesson "The OAuth flow requires the redirect URL to match exactly" ``` This knowledge is injected into every task prompt automatically. diff --git a/gitbook/core-concepts/step-mode.md b/gitbook/core-concepts/step-mode.md index 266ed2909..7c7d9f4dc 100644 --- a/gitbook/core-concepts/step-mode.md +++ b/gitbook/core-concepts/step-mode.md @@ -5,10 +5,10 @@ Step mode is SF's interactive, one-step-at-a-time workflow. You stay in the loop ## Starting Step Mode ``` -/gsd +/sf ``` -SF reads the state of your `.gsd/` directory and presents a wizard showing what's completed and what's next. It then executes one unit of work and pauses. +SF reads the state of your `.sf/` directory and presents a wizard showing what's completed and what's next. It then executes one unit of work and pauses. ## How It Works @@ -16,7 +16,7 @@ Step mode adapts to your project's current state: | State | What Happens | |-------|-------------| -| No `.gsd/` directory | Starts a discussion flow to capture your project vision | +| No `.sf/` directory | Starts a discussion flow to capture your project vision | | Milestone exists, no roadmap | Opens a discussion or research phase for the milestone | | Roadmap exists, slices pending | Plans the next slice or executes the next task | | Mid-task | Resumes where you left off | @@ -31,10 +31,10 @@ After each unit completes, you see results and decide what to do next. This is i Between steps, you can: -- **Discuss** — `/gsd discuss` to talk through architecture decisions -- **Skip** — `/gsd skip` to prevent a unit from being dispatched -- **Undo** — `/gsd undo` to revert the last completed unit -- **Switch to auto** — `/gsd auto` to let SF continue autonomously +- **Discuss** — `/sf discuss` to talk through architecture decisions +- **Skip** — `/sf skip` to prevent a unit from being dispatched +- **Undo** — `/sf undo` to revert the last completed unit +- **Switch to auto** — `/sf auto` to let SF continue autonomously ## When to Use Step Mode @@ -48,7 +48,7 @@ Between steps, you can: Once you're comfortable with SF's approach, switch to auto mode: ``` -/gsd auto +/sf auto ``` You can always press **Escape** to pause auto mode and return to step-by-step control. diff --git a/gitbook/features/captures.md b/gitbook/features/captures.md index 920945c45..adb018716 100644 --- a/gitbook/features/captures.md +++ b/gitbook/features/captures.md @@ -7,11 +7,11 @@ Captures let you fire-and-forget thoughts during auto-mode execution. Instead of While auto mode is running (or any time): ``` -/gsd capture "add rate limiting to the API endpoints" -/gsd capture "the auth flow should support OAuth, not just JWT" +/sf capture "add rate limiting to the API endpoints" +/sf capture "the auth flow should support OAuth, not just JWT" ``` -Captures are appended to `.gsd/CAPTURES.md` and triaged automatically between tasks. +Captures are appended to `.sf/CAPTURES.md` and triaged automatically between tasks. ## How It Works @@ -44,7 +44,7 @@ Plan-modifying resolutions (inject, replan) require your confirmation. Trigger triage manually at any time: ``` -/gsd triage +/sf triage ``` Useful when you've accumulated several captures and want to process them before the next natural seam. diff --git a/gitbook/features/cost-management.md b/gitbook/features/cost-management.md index 03e7ecf23..7bed34331 100644 --- a/gitbook/features/cost-management.md +++ b/gitbook/features/cost-management.md @@ -4,9 +4,9 @@ SF tracks token usage and cost for every unit of work during auto mode. This dat ## Viewing Costs -**Dashboard:** Press `Ctrl+Alt+G` or type `/gsd status` for real-time cost breakdown. +**Dashboard:** Press `Ctrl+Alt+G` or type `/sf status` for real-time cost breakdown. -**Visualizer:** `/gsd visualize` → Metrics tab for detailed charts. +**Visualizer:** `/sf visualize` → Metrics tab for detailed charts. **Aggregations:** - By phase (research, planning, execution, completion, reassessment) @@ -66,9 +66,9 @@ This spreads your budget across remaining work instead of exhausting it early. ## Tips - Start with `balanced` profile and a generous `budget_ceiling` to establish baseline costs -- Check `/gsd status` after a few slices to see per-slice cost averages +- Check `/sf status` after a few slices to see per-slice cost averages - Switch to `budget` for well-understood, repetitive work - Use `quality` only when architectural decisions are being made - Use per-phase model selection to save: Opus for planning, Sonnet for execution - Enable `dynamic_routing` for automatic model downgrading on simple tasks -- Use `/gsd visualize` → Metrics tab to see where your budget is going +- Use `/sf visualize` → Metrics tab to see where your budget is going diff --git a/gitbook/features/dynamic-model-routing.md b/gitbook/features/dynamic-model-routing.md index b34d45440..0749da701 100644 --- a/gitbook/features/dynamic-model-routing.md +++ b/gitbook/features/dynamic-model-routing.md @@ -75,14 +75,14 @@ The `budget` profile + dynamic routing provides maximum cost savings. ## Adaptive Learning -SF tracks routing outcomes in `.gsd/routing-history.json`. If a tier's failure rate exceeds 20% for a given task type, future classifications are bumped up. +SF tracks routing outcomes in `.sf/routing-history.json`. If a tier's failure rate exceeds 20% for a given task type, future classifications are bumped up. -Use `/gsd rate` to submit feedback: +Use `/sf rate` to submit feedback: ``` -/gsd rate over # too powerful — use cheaper next time -/gsd rate ok # just right -/gsd rate under # too weak — use stronger next time +/sf rate over # too powerful — use cheaper next time +/sf rate ok # just right +/sf rate under # too weak — use stronger next time ``` Feedback is weighted 2x compared to automatic outcomes. diff --git a/gitbook/features/github-sync.md b/gitbook/features/github-sync.md index 57218fa70..3d3503075 100644 --- a/gitbook/features/github-sync.md +++ b/gitbook/features/github-sync.md @@ -14,14 +14,14 @@ SF can auto-sync milestones, slices, and tasks to GitHub Issues, PRs, and Milest github: enabled: true repo: "owner/repo" # auto-detected from git remote if omitted - labels: [gsd, auto-generated] # labels for created items + labels: [sf, auto-generated] # labels for created items ``` ## Commands | Command | Description | |---------|-------------| -| `/github-sync bootstrap` | Initial setup — creates GitHub Milestones, Issues, and draft PRs from current `.gsd/` state | +| `/github-sync bootstrap` | Initial setup — creates GitHub Milestones, Issues, and draft PRs from current `.sf/` state | | `/github-sync status` | Show sync mapping counts (milestones, slices, tasks) | ## How It Works @@ -31,7 +31,7 @@ SF can auto-sync milestones, slices, and tasks to GitHub Issues, PRs, and Milest - Tasks → GitHub Issue checklists - Completed slices → Draft PRs -Sync mapping is persisted in `.gsd/.github-sync.json`. The sync is rate-limit aware — it skips when the GitHub API rate limit is low. +Sync mapping is persisted in `.sf/.github-sync.json`. The sync is rate-limit aware — it skips when the GitHub API rate limit is low. ## Configuration @@ -39,6 +39,6 @@ Sync mapping is persisted in `.gsd/.github-sync.json`. The sync is rate-limit aw github: enabled: true repo: "owner/repo" - labels: [gsd, auto-generated] + labels: [sf, auto-generated] project: "Project ID" # optional: GitHub Project board ``` diff --git a/gitbook/features/headless.md b/gitbook/features/headless.md index ec047c426..fce3f94f8 100644 --- a/gitbook/features/headless.md +++ b/gitbook/features/headless.md @@ -1,37 +1,37 @@ # Headless & CI Mode -`gsd headless` runs SF commands without a terminal UI — designed for CI pipelines, cron jobs, and scripted automation. +`sf headless` runs SF commands without a terminal UI — designed for CI pipelines, cron jobs, and scripted automation. ## Basic Usage ```bash # Run auto mode -gsd headless +sf headless # Run a single unit -gsd headless next +sf headless next # With timeout for CI -gsd headless --timeout 600000 auto +sf headless --timeout 600000 auto # Force a specific phase -gsd headless dispatch plan +sf headless dispatch plan # Stream all events as JSONL -gsd headless --json auto +sf headless --json auto ``` ## Creating Milestones Headlessly ```bash # From a context file -gsd headless new-milestone --context brief.md --auto +sf headless new-milestone --context brief.md --auto # From inline text -gsd headless new-milestone --context-text "Build a REST API with auth" +sf headless new-milestone --context-text "Build a REST API with auth" # Pipe from stdin -echo "Build a CLI tool" | gsd headless new-milestone --context - +echo "Build a CLI tool" | sf headless new-milestone --context - ``` ## CLI Flags @@ -56,27 +56,27 @@ echo "Build a CLI tool" | gsd headless new-milestone --context - ## Instant State Query -`gsd headless query` returns a JSON snapshot of project state — no AI session, instant response (~50ms): +`sf headless query` returns a JSON snapshot of project state — no AI session, instant response (~50ms): ```bash -gsd headless query | jq '.state.phase' +sf headless query | jq '.state.phase' # "executing" -gsd headless query | jq '.next' +sf headless query | jq '.next' # {"action":"dispatch","unitType":"execute-task","unitId":"M001/S01/T03"} -gsd headless query | jq '.cost.total' +sf headless query | jq '.cost.total' # 4.25 ``` -Any `/gsd` subcommand works as a positional argument: `gsd headless status`, `gsd headless doctor`, etc. +Any `/sf` subcommand works as a positional argument: `sf headless status`, `sf headless doctor`, etc. ## MCP Server Mode -`gsd --mode mcp` runs SF as a Model Context Protocol server over stdin/stdout, exposing all SF tools to external AI clients: +`sf --mode mcp` runs SF as a Model Context Protocol server over stdin/stdout, exposing all SF tools to external AI clients: ```bash -gsd --mode mcp +sf --mode mcp ``` Compatible with Claude Desktop, VS Code Copilot, and any MCP host. diff --git a/gitbook/features/parallel.md b/gitbook/features/parallel.md index 120e64fff..4017a4464 100644 --- a/gitbook/features/parallel.md +++ b/gitbook/features/parallel.md @@ -3,7 +3,7 @@ Run multiple milestones simultaneously in isolated git worktrees. Each milestone gets its own worker process, branch, and context window. {% hint style="info" %} -Parallel mode is off by default. Enable it in preferences to use `/gsd parallel` commands. +Parallel mode is off by default. Enable it in preferences to use `/sf parallel` commands. {% endhint %} ## Quick Start @@ -17,18 +17,18 @@ Parallel mode is off by default. Enable it in preferences to use `/gsd parallel` 2. Start parallel execution: ``` - /gsd parallel start + /sf parallel start ``` SF scans milestones, checks dependencies and file overlap, shows an eligibility report, and spawns workers. 3. Monitor: ``` - /gsd parallel status + /sf parallel status ``` 4. Stop: ``` - /gsd parallel stop + /sf parallel stop ``` ## How It Works @@ -43,7 +43,7 @@ Each worker is a separate SF process with complete isolation: | Metrics | Own `metrics.json` | | Crash recovery | Own `auto.lock` | -Workers communicate with the coordinator through file-based IPC — heartbeat files and signal files in `.gsd/parallel/`. +Workers communicate with the coordinator through file-based IPC — heartbeat files and signal files in `.sf/parallel/`. ## Eligibility @@ -68,19 +68,19 @@ parallel: | Command | Description | |---------|-------------| -| `/gsd parallel start` | Analyze and start workers | -| `/gsd parallel status` | Show all workers with progress and cost | -| `/gsd parallel stop [MID]` | Stop all or a specific worker | -| `/gsd parallel pause [MID]` | Pause all or a specific worker | -| `/gsd parallel resume [MID]` | Resume paused workers | -| `/gsd parallel merge [MID]` | Merge completed milestones to main | +| `/sf parallel start` | Analyze and start workers | +| `/sf parallel status` | Show all workers with progress and cost | +| `/sf parallel stop [MID]` | Stop all or a specific worker | +| `/sf parallel pause [MID]` | Pause all or a specific worker | +| `/sf parallel resume [MID]` | Resume paused workers | +| `/sf parallel merge [MID]` | Merge completed milestones to main | ## Merge Reconciliation When milestones complete, their changes merge back to main: -- `.gsd/` state files are auto-resolved -- Code conflicts halt the merge — resolve manually and retry with `/gsd parallel merge ` +- `.sf/` state files are auto-resolved +- Code conflicts halt the merge — resolve manually and retry with `/sf parallel merge ` ## Budget Management @@ -91,7 +91,7 @@ When `budget_ceiling` is set, aggregate cost across all workers is tracked. When | Problem | Fix | |---------|-----| | "Parallel mode is not enabled" | Set `parallel.enabled: true` | -| "No eligible milestones" | All milestones are complete or blocked; check `/gsd queue` | -| Worker crashed | Run `/gsd doctor --fix`, then `/gsd parallel start` | -| Merge conflicts | Resolve in `.gsd/worktrees//`, then `/gsd parallel merge ` | -| Workers seem stuck | Check if budget ceiling was reached via `/gsd parallel status` | +| "No eligible milestones" | All milestones are complete or blocked; check `/sf queue` | +| Worker crashed | Run `/sf doctor --fix`, then `/sf parallel start` | +| Merge conflicts | Resolve in `.sf/worktrees//`, then `/sf parallel merge ` | +| Workers seem stuck | Check if budget ceiling was reached via `/sf parallel status` | diff --git a/gitbook/features/remote-questions.md b/gitbook/features/remote-questions.md index 40d1e4fa0..f4e3daa97 100644 --- a/gitbook/features/remote-questions.md +++ b/gitbook/features/remote-questions.md @@ -7,7 +7,7 @@ Remote questions let SF ask for your input via Slack, Discord, or Telegram when ### Discord ``` -/gsd remote discord +/sf remote discord ``` The wizard prompts for your bot token, validates it, lets you pick a server and channel, sends a test message, and saves the config. @@ -20,7 +20,7 @@ The wizard prompts for your bot token, validates it, lets you pick a server and ### Slack ``` -/gsd remote slack +/sf remote slack ``` **Bot requirements:** @@ -31,7 +31,7 @@ The wizard prompts for your bot token, validates it, lets you pick a server and ### Telegram ``` -/gsd remote telegram +/sf remote telegram ``` **Bot requirements:** @@ -74,12 +74,12 @@ If no response arrives within `timeout_minutes`, SF continues with a timeout res | Command | Description | |---------|-------------| -| `/gsd remote` | Show menu and current status | -| `/gsd remote slack` | Set up Slack | -| `/gsd remote discord` | Set up Discord | -| `/gsd remote telegram` | Set up Telegram | -| `/gsd remote status` | Show current config | -| `/gsd remote disconnect` | Remove configuration | +| `/sf remote` | Show menu and current status | +| `/sf remote slack` | Set up Slack | +| `/sf remote discord` | Set up Discord | +| `/sf remote telegram` | Set up Telegram | +| `/sf remote status` | Show current config | +| `/sf remote disconnect` | Remove configuration | ## Troubleshooting diff --git a/gitbook/features/skills.md b/gitbook/features/skills.md index 8d9e9ba1c..00b2a29d6 100644 --- a/gitbook/features/skills.md +++ b/gitbook/features/skills.md @@ -36,7 +36,7 @@ npx skills update ## Onboarding Catalog -During `gsd init`, SF detects your project's tech stack and recommends relevant skill packs: +During `sf init`, SF detects your project's tech stack and recommends relevant skill packs: - **Swift** — SwiftUI, Swift Core, concurrency, Charts, Testing - **iOS** — App Intents, Widgets, StoreKit, MapKit, Core ML, Vision, accessibility @@ -100,10 +100,10 @@ Project-local skills can be committed to git so team members share the same skil Track skill performance: ``` -/gsd skill-health # overview table -/gsd skill-health rust-core # detailed view for one skill -/gsd skill-health --stale 30 # skills unused for 30+ days -/gsd skill-health --declining # skills with falling success rates +/sf skill-health # overview table +/sf skill-health rust-core # detailed view for one skill +/sf skill-health --stale 30 # skills unused for 30+ days +/sf skill-health --declining # skills with falling success rates ``` The dashboard flags: diff --git a/gitbook/features/teams.md b/gitbook/features/teams.md index 9325192b2..98ab368e7 100644 --- a/gitbook/features/teams.md +++ b/gitbook/features/teams.md @@ -7,7 +7,7 @@ SF supports multi-user workflows where several developers work on the same repos The simplest way: set team mode in your project preferences. ```yaml -# .gsd/PREFERENCES.md (committed to git) +# .sf/PREFERENCES.md (committed to git) --- version: 1 mode: team @@ -32,23 +32,23 @@ Share planning artifacts while keeping runtime files local: ```bash # Runtime files (per-developer, gitignore these) -.gsd/auto.lock -.gsd/completed-units.json -.gsd/STATE.md -.gsd/metrics.json -.gsd/activity/ -.gsd/runtime/ -.gsd/worktrees/ -.gsd/milestones/**/continue.md -.gsd/milestones/**/*-CONTINUE.md +.sf/auto.lock +.sf/completed-units.json +.sf/STATE.md +.sf/metrics.json +.sf/activity/ +.sf/runtime/ +.sf/worktrees/ +.sf/milestones/**/continue.md +.sf/milestones/**/*-CONTINUE.md ``` **What gets shared** (committed to git): -- `.gsd/PREFERENCES.md` — project preferences -- `.gsd/PROJECT.md` — living project description -- `.gsd/REQUIREMENTS.md` — requirement contract -- `.gsd/DECISIONS.md` — architectural decisions -- `.gsd/milestones/` — roadmaps, plans, summaries, research +- `.sf/PREFERENCES.md` — project preferences +- `.sf/PROJECT.md` — living project description +- `.sf/REQUIREMENTS.md` — requirement contract +- `.sf/DECISIONS.md` — architectural decisions +- `.sf/milestones/` — roadmaps, plans, summaries, research **What stays local** (gitignored): - Lock files, metrics, state, activity logs, worktrees @@ -56,11 +56,11 @@ Share planning artifacts while keeping runtime files local: ## Commit the Config ```bash -git add .gsd/PREFERENCES.md +git add .sf/PREFERENCES.md git commit -m "chore: enable SF team workflow" ``` -## Keeping `.gsd/` Local +## Keeping `.sf/` Local For teams where only some members use SF: @@ -69,13 +69,13 @@ git: commit_docs: false ``` -This gitignores `.gsd/` entirely. You get structured planning without affecting teammates. +This gitignores `.sf/` entirely. You get structured planning without affecting teammates. ## Parallel Development Multiple developers can run auto mode simultaneously on different milestones. Each developer: -- Gets their own worktree (`.gsd/worktrees//`) +- Gets their own worktree (`.sf/worktrees//`) - Works on a unique `milestone/` branch - Squash-merges to main independently diff --git a/gitbook/features/token-optimization.md b/gitbook/features/token-optimization.md index cbaaabbd7..5a16979c2 100644 --- a/gitbook/features/token-optimization.md +++ b/gitbook/features/token-optimization.md @@ -91,9 +91,9 @@ SF tracks success and failure of tier assignments over time. If a model tier's f Submit manual feedback with: ``` -/gsd rate over # model was overpowered — use cheaper next time -/gsd rate ok # model was appropriate -/gsd rate under # model was too weak — use stronger next time +/sf rate over # model was overpowered — use cheaper next time +/sf rate ok # model was appropriate +/sf rate under # model was too weak — use stronger next time ``` ## Observation Masking diff --git a/gitbook/features/visualizer.md b/gitbook/features/visualizer.md index 4155ec144..8e1f4b1c7 100644 --- a/gitbook/features/visualizer.md +++ b/gitbook/features/visualizer.md @@ -5,7 +5,7 @@ The workflow visualizer is a full-screen terminal overlay showing project progre ## Opening ``` -/gsd visualize +/sf visualize ``` Or configure automatic display after milestone completion: @@ -71,11 +71,11 @@ The visualizer auto-refreshes every 2 seconds, staying current alongside running For shareable reports outside the terminal: ``` -/gsd export --html # current milestone -/gsd export --html --all # all milestones +/sf export --html # current milestone +/sf export --html --all # all milestones ``` -Generates self-contained HTML files in `.gsd/reports/` with progress tree, dependency graph, cost charts, timeline, and changelog. All CSS and JS are inlined — no external dependencies. Printable to PDF from any browser. +Generates self-contained HTML files in `.sf/reports/` with progress tree, dependency graph, cost charts, timeline, and changelog. All CSS and JS are inlined — no external dependencies. Printable to PDF from any browser. ```yaml auto_report: true # auto-generate after milestone completion (default) diff --git a/gitbook/features/web-interface.md b/gitbook/features/web-interface.md index d7d985261..7f11a734d 100644 --- a/gitbook/features/web-interface.md +++ b/gitbook/features/web-interface.md @@ -5,7 +5,7 @@ SF includes a browser-based interface for project management and real-time progr ## Quick Start ```bash -gsd --web +sf --web ``` This starts a local web server and opens the dashboard in your default browser. @@ -13,7 +13,7 @@ This starts a local web server and opens the dashboard in your default browser. ## CLI Flags ```bash -gsd --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" +sf --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" ``` | Flag | Default | Description | diff --git a/gitbook/features/workflow-templates.md b/gitbook/features/workflow-templates.md index 45246a33b..dfd1afe84 100644 --- a/gitbook/features/workflow-templates.md +++ b/gitbook/features/workflow-templates.md @@ -5,8 +5,8 @@ Workflow templates are pre-built patterns for common development tasks. Instead ## Using Templates ``` -/gsd start # pick from available templates -/gsd start resume # resume an in-progress workflow +/sf start # pick from available templates +/sf start resume # resume an in-progress workflow ``` ## Available Templates @@ -25,8 +25,8 @@ Workflow templates are pre-built patterns for common development tasks. Instead ## Listing and Inspecting ``` -/gsd templates # list all available templates -/gsd templates info # show details for a template +/sf templates # list all available templates +/sf templates info # show details for a template ``` ## Custom Workflows @@ -34,12 +34,12 @@ Workflow templates are pre-built patterns for common development tasks. Instead Create your own workflow definitions: ``` -/gsd workflow new # create a new workflow YAML -/gsd workflow run # start a workflow run -/gsd workflow list # list active runs -/gsd workflow validate # validate definition -/gsd workflow pause # pause running workflow -/gsd workflow resume # resume paused workflow +/sf workflow new # create a new workflow YAML +/sf workflow run # start a workflow run +/sf workflow list # list active runs +/sf workflow validate # validate definition +/sf workflow pause # pause running workflow +/sf workflow resume # resume paused workflow ``` Custom workflows are defined in YAML and can specify phases, dependencies, and configuration for each step. diff --git a/gitbook/getting-started/first-project.md b/gitbook/getting-started/first-project.md index 333369478..c8fc63012 100644 --- a/gitbook/getting-started/first-project.md +++ b/gitbook/getting-started/first-project.md @@ -5,16 +5,16 @@ Open a terminal in any project directory (or an empty one) and run: ```bash -gsd +sf ``` SF shows a welcome screen with your version, active model, and available tool keys. ## Start a Discussion -Type `/gsd` to enter step mode. SF reads the state of your project directory and determines the next logical action: +Type `/sf` to enter step mode. SF reads the state of your project directory and determines the next logical action: -- **No `.gsd/` directory** — starts a discussion flow to capture your project vision +- **No `.sf/` directory** — starts a discussion flow to capture your project vision - **Milestone exists, no roadmap** — discuss or research the milestone - **Roadmap exists, slices pending** — plan the next slice or execute a task - **Mid-task** — resume where you left off @@ -38,7 +38,7 @@ The key rule: **a task must fit in one AI context window.** If it can't, it beco Once you have a milestone and roadmap, let SF take the wheel: ``` -/gsd auto +/sf auto ``` SF autonomously: @@ -55,25 +55,25 @@ The recommended approach: auto mode in one terminal, steering from another. **Terminal 1 — let it build:** ```bash -gsd -/gsd auto +sf +/sf auto ``` **Terminal 2 — steer while it works:** ```bash -gsd -/gsd discuss # talk through architecture decisions -/gsd status # check progress -/gsd queue # queue the next milestone -/gsd capture "add rate limiting to the API" # fire-and-forget thought +sf +/sf discuss # talk through architecture decisions +/sf status # check progress +/sf queue # queue the next milestone +/sf capture "add rate limiting to the API" # fire-and-forget thought ``` -Both terminals read and write the same `.gsd/` files. Decisions in terminal 2 are picked up at the next phase boundary automatically. +Both terminals read and write the same `.sf/` files. Decisions in terminal 2 are picked up at the next phase boundary automatically. ## Check Progress -Press `Ctrl+Alt+G` or type `/gsd status` to see the dashboard: +Press `Ctrl+Alt+G` or type `/sf status` to see the dashboard: - Current milestone, slice, and task - Elapsed time and phase @@ -83,7 +83,7 @@ Press `Ctrl+Alt+G` or type `/gsd status` to see the dashboard: ## Resume a Session ```bash -gsd --continue # or gsd -c +sf --continue # or sf -c ``` Resumes the most recent session for the current directory. @@ -91,17 +91,17 @@ Resumes the most recent session for the current directory. To browse and pick from all saved sessions: ```bash -gsd sessions +sf sessions ``` Shows each session's date, message count, and preview so you can choose which to resume. ## What's on Disk -All state lives in `.gsd/` inside your project: +All state lives in `.sf/` inside your project: ``` -.gsd/ +.sf/ PROJECT.md — what the project is REQUIREMENTS.md — requirement contract DECISIONS.md — architectural decisions diff --git a/gitbook/getting-started/installation.md b/gitbook/getting-started/installation.md index 264528e62..5b5e69816 100644 --- a/gitbook/getting-started/installation.md +++ b/gitbook/getting-started/installation.md @@ -9,17 +9,17 @@ npm install -g sf-run Requires **Node.js 22.0.0 or later** (24 LTS recommended) and **Git**. {% hint style="info" %} -**`command not found: gsd`?** Your shell may not have npm's global bin directory in `$PATH`. Run `npm prefix -g` to find it, then add `$(npm prefix -g)/bin` to your PATH. See [Troubleshooting](../reference/troubleshooting.md) for details. +**`command not found: sf`?** Your shell may not have npm's global bin directory in `$PATH`. Run `npm prefix -g` to find it, then add `$(npm prefix -g)/bin` to your PATH. See [Troubleshooting](../reference/troubleshooting.md) for details. {% endhint %} -SF checks for updates once every 24 hours. When a new version is available, you'll see a prompt at startup with the option to update immediately or skip. You can also update from within a session with `/gsd update`. +SF checks for updates once every 24 hours. When a new version is available, you'll see a prompt at startup with the option to update immediately or skip. You can also update from within a session with `/sf update`. ## Set Up Your LLM Provider Launch SF for the first time: ```bash -gsd +sf ``` The setup wizard walks you through: @@ -30,14 +30,14 @@ The setup wizard walks you through: Re-run the wizard anytime with: ```bash -gsd config +sf config ``` For detailed provider setup, see [Provider Setup](../configuration/providers.md). ## Set Up API Keys for Tools -If you use a non-Anthropic model, you may need a search API key for web search. Run `/gsd config` inside any SF session to set keys globally — they're saved to `~/.gsd/agent/auth.json` and apply to all projects. +If you use a non-Anthropic model, you may need a search API key for web search. Run `/sf config` inside any SF session to set keys globally — they're saved to `~/.sf/agent/auth.json` and apply to all projects. | Tool | Purpose | Get a Key | |------|---------|-----------| @@ -53,7 +53,7 @@ SF is also available as a VS Code extension. Install from the marketplace (publi The extension provides: -- **`@gsd` chat participant** — talk to the agent in VS Code Chat +- **`@sf` chat participant** — talk to the agent in VS Code Chat - **Sidebar dashboard** — connection status, model info, token usage, quick actions - **Full command palette** — start/stop agent, switch models, export sessions @@ -64,21 +64,21 @@ The CLI (`sf-run`) must be installed first — the extension connects to it via SF also has a browser-based interface: ```bash -gsd --web +sf --web ``` This starts a local web server with a visual dashboard, real-time progress, and multi-project support. See [Web Interface](../features/web-interface.md) for details. ## Alternative Binary Name -If the `gsd` command conflicts with another tool (e.g., the oh-my-zsh git plugin aliases `gsd` to `git svn dcommit`), use the alternative: +If the `sf` command conflicts with another tool (e.g., the oh-my-zsh git plugin aliases `sf` to `git svn dcommit`), use the alternative: ```bash -gsd-cli +sf-cli ``` -Both `gsd` and `gsd-cli` point to the same binary. To remove the conflict permanently, add this to your `~/.zshrc`: +Both `sf` and `sf-cli` point to the same binary. To remove the conflict permanently, add this to your `~/.zshrc`: ```bash -unalias gsd 2>/dev/null +unalias sf 2>/dev/null ``` diff --git a/gitbook/reference/cli-flags.md b/gitbook/reference/cli-flags.md index 146eb8d3b..622a91bc6 100644 --- a/gitbook/reference/cli-flags.md +++ b/gitbook/reference/cli-flags.md @@ -4,53 +4,53 @@ | Flag | Description | |------|-------------| -| `gsd` | Start a new interactive session | -| `gsd --continue` (`-c`) | Resume the most recent session | -| `gsd --model ` | Override the default model for this session | -| `gsd --web [path]` | Start browser-based web interface | -| `gsd --worktree` (`-w`) [name] | Start in a git worktree | -| `gsd --no-session` | Disable session persistence | -| `gsd --extension ` | Load an additional extension (repeatable) | -| `gsd --append-system-prompt ` | Append text to the system prompt | -| `gsd --tools ` | Comma-separated tools to enable | -| `gsd --version` (`-v`) | Print version and exit | -| `gsd --help` (`-h`) | Print help and exit | -| `gsd --debug` | Enable diagnostic logging | +| `sf` | Start a new interactive session | +| `sf --continue` (`-c`) | Resume the most recent session | +| `sf --model ` | Override the default model for this session | +| `sf --web [path]` | Start browser-based web interface | +| `sf --worktree` (`-w`) [name] | Start in a git worktree | +| `sf --no-session` | Disable session persistence | +| `sf --extension ` | Load an additional extension (repeatable) | +| `sf --append-system-prompt ` | Append text to the system prompt | +| `sf --tools ` | Comma-separated tools to enable | +| `sf --version` (`-v`) | Print version and exit | +| `sf --help` (`-h`) | Print help and exit | +| `sf --debug` | Enable diagnostic logging | ## Non-Interactive Modes | Flag | Description | |------|-------------| -| `gsd --print "msg"` (`-p`) | Single-shot prompt mode (no TUI) | -| `gsd --mode ` | Output mode for non-interactive use | +| `sf --print "msg"` (`-p`) | Single-shot prompt mode (no TUI) | +| `sf --mode ` | Output mode for non-interactive use | ## Session Management | Command | Description | |---------|-------------| -| `gsd sessions` | Interactive session picker — list and resume saved sessions | -| `gsd --list-models [search]` | List available models and exit | +| `sf sessions` | Interactive session picker — list and resume saved sessions | +| `sf --list-models [search]` | List available models and exit | ## Configuration | Command | Description | |---------|-------------| -| `gsd config` | Set up global API keys | -| `gsd update` | Update to the latest version | +| `sf config` | Set up global API keys | +| `sf update` | Update to the latest version | ## Headless Mode | Flag | Description | |------|-------------| -| `gsd headless` | Run without TUI | -| `gsd headless --timeout N` | Timeout in ms (default: 300000) | -| `gsd headless --max-restarts N` | Auto-restart on crash (default: 3) | -| `gsd headless --json` | Stream events as JSONL | -| `gsd headless --model ID` | Override model | -| `gsd headless --context ` | Context file for `new-milestone` | -| `gsd headless --context-text ` | Inline context for `new-milestone` | -| `gsd headless --auto` | Chain into auto mode after milestone creation | -| `gsd headless query` | Instant JSON state snapshot (~50ms) | +| `sf headless` | Run without TUI | +| `sf headless --timeout N` | Timeout in ms (default: 300000) | +| `sf headless --max-restarts N` | Auto-restart on crash (default: 3) | +| `sf headless --json` | Stream events as JSONL | +| `sf headless --model ID` | Override model | +| `sf headless --context ` | Context file for `new-milestone` | +| `sf headless --context-text ` | Inline context for `new-milestone` | +| `sf headless --auto` | Chain into auto mode after milestone creation | +| `sf headless query` | Instant JSON state snapshot (~50ms) | ## Web Interface diff --git a/gitbook/reference/commands.md b/gitbook/reference/commands.md index e49ba29e2..991607981 100644 --- a/gitbook/reference/commands.md +++ b/gitbook/reference/commands.md @@ -4,101 +4,101 @@ | Command | Description | |---------|-------------| -| `/gsd` | Step mode — execute one unit at a time | -| `/gsd auto` | Autonomous mode — research, plan, execute, commit, repeat | -| `/gsd quick` | Quick task with SF guarantees but no full planning | -| `/gsd stop` | Stop auto mode gracefully | -| `/gsd pause` | Pause auto mode (preserves state) | -| `/gsd steer` | Modify plan documents during execution | -| `/gsd discuss` | Discuss architecture and decisions | -| `/gsd status` | Progress dashboard | -| `/gsd widget` | Cycle dashboard widget: full / small / min / off | -| `/gsd queue` | Queue and reorder future milestones | -| `/gsd capture` | Fire-and-forget thought capture | -| `/gsd triage` | Manually trigger capture triage | -| `/gsd dispatch` | Dispatch a specific phase directly | -| `/gsd history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | -| `/gsd forensics` | Full debugger for auto-mode failures | -| `/gsd cleanup` | Clean up state files and stale worktrees | -| `/gsd visualize` | Open workflow visualizer | -| `/gsd export --html` | Generate HTML report for current milestone | -| `/gsd export --html --all` | Generate reports for all milestones | -| `/gsd update` | Update SF to the latest version | -| `/gsd knowledge` | Add persistent project knowledge | -| `/gsd fast` | Toggle service tier for supported models | -| `/gsd rate` | Rate last unit's model tier (over/ok/under) | -| `/gsd changelog` | Show release notes | -| `/gsd logs` | Browse activity and debug logs | -| `/gsd remote` | Control remote auto-mode | -| `/gsd help` | Show all available commands | +| `/sf` | Step mode — execute one unit at a time | +| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat | +| `/sf quick` | Quick task with SF guarantees but no full planning | +| `/sf stop` | Stop auto mode gracefully | +| `/sf pause` | Pause auto mode (preserves state) | +| `/sf steer` | Modify plan documents during execution | +| `/sf discuss` | Discuss architecture and decisions | +| `/sf status` | Progress dashboard | +| `/sf widget` | Cycle dashboard widget: full / small / min / off | +| `/sf queue` | Queue and reorder future milestones | +| `/sf capture` | Fire-and-forget thought capture | +| `/sf triage` | Manually trigger capture triage | +| `/sf dispatch` | Dispatch a specific phase directly | +| `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | +| `/sf forensics` | Full debugger for auto-mode failures | +| `/sf cleanup` | Clean up state files and stale worktrees | +| `/sf visualize` | Open workflow visualizer | +| `/sf export --html` | Generate HTML report for current milestone | +| `/sf export --html --all` | Generate reports for all milestones | +| `/sf update` | Update SF to the latest version | +| `/sf knowledge` | Add persistent project knowledge | +| `/sf fast` | Toggle service tier for supported models | +| `/sf rate` | Rate last unit's model tier (over/ok/under) | +| `/sf changelog` | Show release notes | +| `/sf logs` | Browse activity and debug logs | +| `/sf remote` | Control remote auto-mode | +| `/sf help` | Show all available commands | ## Configuration & Diagnostics | Command | Description | |---------|-------------| -| `/gsd prefs` | Preferences wizard | -| `/gsd mode` | Switch workflow mode (solo/team) | -| `/gsd config` | Re-run provider setup wizard | -| `/gsd keys` | API key manager | -| `/gsd doctor` | Runtime health checks with auto-fix | -| `/gsd inspect` | Show database diagnostics | -| `/gsd init` | Project init wizard | -| `/gsd setup` | Global setup status | -| `/gsd skill-health` | Skill lifecycle dashboard | -| `/gsd hooks` | Show configured hooks | -| `/gsd migrate` | Migrate v1 `.planning` to `.gsd` format | +| `/sf prefs` | Preferences wizard | +| `/sf mode` | Switch workflow mode (solo/team) | +| `/sf config` | Re-run provider setup wizard | +| `/sf keys` | API key manager | +| `/sf doctor` | Runtime health checks with auto-fix | +| `/sf inspect` | Show database diagnostics | +| `/sf init` | Project init wizard | +| `/sf setup` | Global setup status | +| `/sf skill-health` | Skill lifecycle dashboard | +| `/sf hooks` | Show configured hooks | +| `/sf migrate` | Migrate v1 `.planning` to `.sf` format | ## Milestone Management | Command | Description | |---------|-------------| -| `/gsd new-milestone` | Create a new milestone | -| `/gsd skip` | Prevent a unit from auto-mode dispatch | -| `/gsd undo` | Revert last completed unit | -| `/gsd undo-task` | Reset a specific task's completion state | -| `/gsd reset-slice` | Reset a slice and all its tasks | -| `/gsd park` | Park a milestone (skip without deleting) | -| `/gsd unpark` | Reactivate a parked milestone | +| `/sf new-milestone` | Create a new milestone | +| `/sf skip` | Prevent a unit from auto-mode dispatch | +| `/sf undo` | Revert last completed unit | +| `/sf undo-task` | Reset a specific task's completion state | +| `/sf reset-slice` | Reset a slice and all its tasks | +| `/sf park` | Park a milestone (skip without deleting) | +| `/sf unpark` | Reactivate a parked milestone | ## Parallel Orchestration | Command | Description | |---------|-------------| -| `/gsd parallel start` | Analyze and start parallel workers | -| `/gsd parallel status` | Show worker state and progress | -| `/gsd parallel stop [MID]` | Stop workers | -| `/gsd parallel pause [MID]` | Pause workers | -| `/gsd parallel resume [MID]` | Resume workers | -| `/gsd parallel merge [MID]` | Merge completed milestones | +| `/sf parallel start` | Analyze and start parallel workers | +| `/sf parallel status` | Show worker state and progress | +| `/sf parallel stop [MID]` | Stop workers | +| `/sf parallel pause [MID]` | Pause workers | +| `/sf parallel resume [MID]` | Resume workers | +| `/sf parallel merge [MID]` | Merge completed milestones | ## Workflow Templates | Command | Description | |---------|-------------| -| `/gsd start` | Start a workflow template | -| `/gsd start resume` | Resume an in-progress workflow | -| `/gsd templates` | List available templates | -| `/gsd templates info ` | Show template details | +| `/sf start` | Start a workflow template | +| `/sf start resume` | Resume an in-progress workflow | +| `/sf templates` | List available templates | +| `/sf templates info ` | Show template details | ## Custom Workflows | Command | Description | |---------|-------------| -| `/gsd workflow new` | Create a workflow definition | -| `/gsd workflow run ` | Start a workflow run | -| `/gsd workflow list` | List workflow runs | -| `/gsd workflow validate ` | Validate a workflow YAML | -| `/gsd workflow pause` | Pause workflow auto-mode | -| `/gsd workflow resume` | Resume paused workflow | +| `/sf workflow new` | Create a workflow definition | +| `/sf workflow run ` | Start a workflow run | +| `/sf workflow list` | List workflow runs | +| `/sf workflow validate ` | Validate a workflow YAML | +| `/sf workflow pause` | Pause workflow auto-mode | +| `/sf workflow resume` | Resume paused workflow | ## Extensions | Command | Description | |---------|-------------| -| `/gsd extensions list` | List all extensions | -| `/gsd extensions enable ` | Enable an extension | -| `/gsd extensions disable ` | Disable an extension | -| `/gsd extensions info ` | Show extension details | +| `/sf extensions list` | List all extensions | +| `/sf extensions enable ` | Enable an extension | +| `/sf extensions disable ` | Disable an extension | +| `/sf extensions info ` | Show extension details | ## GitHub Sync @@ -122,7 +122,7 @@ ## In-Session Update ``` -/gsd update +/sf update ``` Checks npm for a newer version and installs it without leaving the session. diff --git a/gitbook/reference/environment-variables.md b/gitbook/reference/environment-variables.md index c48971244..1080f930e 100644 --- a/gitbook/reference/environment-variables.md +++ b/gitbook/reference/environment-variables.md @@ -4,13 +4,13 @@ | Variable | Default | Description | |----------|---------|-------------| -| `SF_HOME` | `~/.gsd` | Global SF directory. All paths derive from this unless individually overridden. | +| `SF_HOME` | `~/.sf` | Global SF directory. All paths derive from this unless individually overridden. | | `SF_PROJECT_ID` | (auto-hash) | Override automatic project identity hash. Useful for CI/CD or sharing state across repo clones. | | `SF_STATE_DIR` | `$SF_HOME` | Per-project state root. Controls where `projects//` directories are created. | | `SF_CODING_AGENT_DIR` | `$SF_HOME/agent` | Agent directory for extensions, auth, and managed resources. | | `SF_FETCH_ALLOWED_URLS` | (none) | Comma-separated hostnames exempt from internal URL blocking. | | `SF_ALLOWED_COMMAND_PREFIXES` | (built-in) | Comma-separated command prefixes allowed for value resolution. | -| `SF_WEB_PROJECT_CWD` | — | Default project path for `gsd --web` when `?project=` is not specified. | +| `SF_WEB_PROJECT_CWD` | — | Default project path for `sf --web` when `?project=` is not specified. | ## LLM Provider Keys @@ -51,6 +51,6 @@ The `fetch_page` tool blocks requests to private/internal networks by default (S export SF_FETCH_ALLOWED_URLS="internal-docs.company.com,192.168.1.50" ``` -Or set `fetchAllowedUrls` in `~/.gsd/agent/settings.json`. +Or set `fetchAllowedUrls` in `~/.sf/agent/settings.json`. Blocked by default: private IP ranges, cloud metadata endpoints, localhost, non-HTTP protocols, IPv6 private ranges. diff --git a/gitbook/reference/keyboard-shortcuts.md b/gitbook/reference/keyboard-shortcuts.md index 58f8d3d9f..ecc5432c2 100644 --- a/gitbook/reference/keyboard-shortcuts.md +++ b/gitbook/reference/keyboard-shortcuts.md @@ -26,8 +26,8 @@ If you use cmux (terminal multiplexer), SF can integrate with it: | Command | Description | |---------|-------------| -| `/gsd cmux status` | Show cmux detection and capabilities | -| `/gsd cmux on` / `off` | Enable/disable integration | -| `/gsd cmux notifications on/off` | Toggle desktop notifications | -| `/gsd cmux sidebar on/off` | Toggle sidebar metadata | -| `/gsd cmux splits on/off` | Toggle visual subagent splits | +| `/sf cmux status` | Show cmux detection and capabilities | +| `/sf cmux on` / `off` | Enable/disable integration | +| `/sf cmux notifications on/off` | Toggle desktop notifications | +| `/sf cmux sidebar on/off` | Toggle sidebar metadata | +| `/sf cmux splits on/off` | Toggle visual subagent splits | diff --git a/gitbook/reference/migration.md b/gitbook/reference/migration.md index bee8b8657..404dbadc9 100644 --- a/gitbook/reference/migration.md +++ b/gitbook/reference/migration.md @@ -1,15 +1,15 @@ # Migration from v1 -If you have projects with `.planning` directories from the original Singularity Forge (v1), you can migrate them to SF's `.gsd` format. +If you have projects with `.planning` directories from the original Singularity Forge (v1), you can migrate them to SF's `.sf` format. ## Running the Migration ```bash # From within the project directory -/gsd migrate +/sf migrate # Or specify a path -/gsd migrate ~/projects/my-old-project +/sf migrate ~/projects/my-old-project ``` ## What Gets Migrated @@ -42,7 +42,7 @@ Migration works best with a `ROADMAP.md` file for milestone structure. Without o After migrating, verify the output: ``` -/gsd doctor +/sf doctor ``` -This checks `.gsd/` integrity and flags any structural issues. +This checks `.sf/` integrity and flags any structural issues. diff --git a/gitbook/reference/troubleshooting.md b/gitbook/reference/troubleshooting.md index 32bc06c56..cfbf6ffd3 100644 --- a/gitbook/reference/troubleshooting.md +++ b/gitbook/reference/troubleshooting.md @@ -1,11 +1,11 @@ # Troubleshooting -## `/gsd doctor` +## `/sf doctor` -The built-in diagnostic tool validates `.gsd/` integrity: +The built-in diagnostic tool validates `.sf/` integrity: ``` -/gsd doctor +/sf doctor ``` It checks file structure, roadmap ↔ slice ↔ task consistency, completion state, git health, stale locks, and orphaned records. @@ -16,15 +16,15 @@ It checks file structure, roadmap ↔ slice ↔ task consistency, completion sta The same unit dispatches repeatedly. -**Fix:** Run `/gsd doctor` to repair state, then `/gsd auto`. If it persists, check that the expected artifact file exists on disk. +**Fix:** Run `/sf doctor` to repair state, then `/sf auto`. If it persists, check that the expected artifact file exists on disk. ### Auto mode stops with "Loop detected" A unit failed to produce its expected artifact twice. -**Fix:** Check the task plan for clarity. Refine it manually, then `/gsd auto`. +**Fix:** Check the task plan for clarity. Refine it manually, then `/sf auto`. -### `command not found: gsd` after install +### `command not found: sf` after install npm's global bin directory isn't in `$PATH`. @@ -39,7 +39,7 @@ source ~/.zshrc **Common causes:** - **Homebrew Node** — `/opt/homebrew/bin` missing from PATH - **Version manager (nvm, fnm, mise)** — global bin is version-specific -- **oh-my-zsh** — `gitfast` plugin aliases `gsd` to `git svn dcommit`; check with `alias gsd` +- **oh-my-zsh** — `gitfast` plugin aliases `sf` to `git svn dcommit`; check with `alias sf` ### Provider errors during auto mode @@ -63,7 +63,7 @@ models: Auto mode pauses with "Budget ceiling reached." -**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/gsd auto`. +**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/sf auto`. ### Stale lock file @@ -72,15 +72,15 @@ Auto mode won't start, says another session is running. **Fix:** SF auto-detects stale locks (dead PID = auto cleanup). If automatic recovery fails: ```bash -rm -f .gsd/auto.lock -rm -rf "$(dirname .gsd)/.gsd.lock" +rm -f .sf/auto.lock +rm -rf "$(dirname .sf)/.sf.lock" ``` ### Git merge conflicts -Worktree merge fails on `.gsd/` files. +Worktree merge fails on `.sf/` files. -**Fix:** `.gsd/` conflicts are auto-resolved. Code conflicts get an AI fix attempt; if that fails, resolve manually. +**Fix:** `.sf/` conflicts are auto-resolved. Code conflicts get an AI fix attempt; if that fails, resolve manually. ### Notifications not appearing on macOS @@ -96,7 +96,7 @@ See [Notifications](../configuration/notifications.md) for details. ### No servers configured -**Fix:** Add server to `.mcp.json` or `.gsd/mcp.json`, verify JSON is valid, run `mcp_servers(refresh=true)`. +**Fix:** Add server to `.mcp.json` or `.sf/mcp.json`, verify JSON is valid, run `mcp_servers(refresh=true)`. ### Server discovery times out @@ -111,32 +111,32 @@ See [Notifications](../configuration/notifications.md) for details. ### Reset auto mode state ```bash -rm .gsd/auto.lock -rm .gsd/completed-units.json +rm .sf/auto.lock +rm .sf/completed-units.json ``` -Then `/gsd auto` to restart from current state. +Then `/sf auto` to restart from current state. ### Reset routing history ```bash -rm .gsd/routing-history.json +rm .sf/routing-history.json ``` ### Full state rebuild ``` -/gsd doctor +/sf doctor ``` Rebuilds `STATE.md` from plan and roadmap files and fixes inconsistencies. ## Getting Help -- **GitHub Issues:** [github.com/gsd-build/SF/issues](https://github.com/gsd-build/SF/issues) -- **Dashboard:** `Ctrl+Alt+G` or `/gsd status` -- **Forensics:** `/gsd forensics` for post-mortem analysis -- **Session logs:** `.gsd/activity/` contains JSONL session dumps +- **GitHub Issues:** [github.com/sf-build/SF/issues](https://github.com/sf-build/SF/issues) +- **Dashboard:** `Ctrl+Alt+G` or `/sf status` +- **Forensics:** `/sf forensics` for post-mortem analysis +- **Session logs:** `.sf/activity/` contains JSONL session dumps ## Platform-Specific Issues @@ -148,4 +148,4 @@ Rebuilds `STATE.md` from plan and roadmap files and fixes inconsistencies. - LSP ENOENT on MSYS2/Git Bash → Fixed in v2.29+, upgrade - EBUSY errors during builds → Close browser extension, or change output directory -- Transient EBUSY/EPERM on `.gsd/` files → Retry; close file-locking tools if persistent +- Transient EBUSY/EPERM on `.sf/` files → Retry; close file-locking tools if persistent diff --git a/gsd-orchestrator/SKILL.md b/gsd-orchestrator/SKILL.md index 2ec2ae289..1475301ab 100644 --- a/gsd-orchestrator/SKILL.md +++ b/gsd-orchestrator/SKILL.md @@ -1,19 +1,19 @@ --- -name: gsd-orchestrator +name: sf-orchestrator description: > Build software products autonomously via SF headless mode. Handles the full lifecycle: write a spec, launch a build, poll for completion, handle blockers, track costs, and verify the result. Use when asked to "build something", - "create a project", "run gsd", "check build status", or any task that + "create a project", "run sf", "check build status", or any task that requires autonomous software development via subprocess. metadata: openclaw: requires: - bins: [gsd] + bins: [sf] install: kind: node package: sf-run - bins: [gsd] + bins: [sf] --- @@ -27,7 +27,7 @@ SF headless is a subprocess you launch and monitor. Think of it like a junior de you hand a spec to: 1. You write the spec (what to build) -2. You launch the build (`gsd headless ... new-milestone --context spec.md --auto`) +2. You launch the build (`sf headless ... new-milestone --context spec.md --auto`) 3. You wait for it to finish (exit code tells you the outcome) 4. You check the result (query state, inspect files, verify deliverables) 5. If blocked, you intervene (steer, supply answers, or escalate) @@ -37,12 +37,12 @@ You never write application code yourself — SF does that. -- **Flags before command.** `gsd headless [--flags] [command] [args]`. Flags after the command are ignored. +- **Flags before command.** `sf headless [--flags] [command] [args]`. Flags after the command are ignored. - **Redirect stderr.** JSON output goes to stdout. Progress goes to stderr. Always `2>/dev/null` when parsing JSON. - **Check exit codes.** 0=success, 1=error, 10=blocked (needs you), 11=cancelled. - **Use `query` to poll.** Instant (~50ms), no LLM cost. Use it between steps, not `auto` for status. - **Budget awareness.** Track `cost.total` from query results. Set limits before launching long runs. -- **One project directory per build.** Each SF project needs its own directory with a `.gsd/` folder. +- **One project directory per build.** Each SF project needs its own directory with a `.sf/` folder. @@ -76,24 +76,24 @@ cat > spec.md << 'EOF' # Your Product Spec Here Build a ... EOF -gsd headless --output-format json --context spec.md new-milestone --auto 2>/dev/null +sf headless --output-format json --context spec.md new-milestone --auto 2>/dev/null ``` **Check project state (instant, free):** ```bash cd /path/to/project -gsd headless query | jq '{phase: .state.phase, progress: .state.progress, cost: .cost.total}' +sf headless query | jq '{phase: .state.phase, progress: .state.progress, cost: .cost.total}' ``` **Resume work on an existing project:** ```bash cd /path/to/project -gsd headless --output-format json auto 2>/dev/null +sf headless --output-format json auto 2>/dev/null ``` **Run one step at a time:** ```bash -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) echo "$RESULT" | jq '{status: .status, phase: .phase, cost: .cost.total}' ``` @@ -103,15 +103,15 @@ echo "$RESULT" | jq '{status: .status, phase: .phase, cost: .cost.total}' | Code | Meaning | Your action | |------|---------|-------------| | `0` | Success | Check deliverables, verify output, report completion | -| `1` | Error or timeout | Inspect stderr, check `.gsd/STATE.md`, retry or escalate | +| `1` | Error or timeout | Inspect stderr, check `.sf/STATE.md`, retry or escalate | | `10` | Blocked | Query state for blocker details, steer around it or escalate to human | | `11` | Cancelled | Process was interrupted — resume with `--resume ` or restart | -SF creates and manages all state in `.gsd/`: +SF creates and manages all state in `.sf/`: ``` -.gsd/ +.sf/ PROJECT.md # What this project is REQUIREMENTS.md # Capability contract DECISIONS.md # Architectural decisions (append-only) @@ -156,7 +156,7 @@ State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are Pre-supply answers and secrets for fully autonomous runs: ```bash -gsd headless --answers answers.json --output-format json auto 2>/dev/null +sf headless --answers answers.json --output-format json auto 2>/dev/null ``` ```json @@ -178,7 +178,7 @@ See `references/answer-injection.md` for the full mechanism. For real-time monitoring, use JSONL event streaming: ```bash -gsd headless --json auto 2>/dev/null | while read -r line; do +sf headless --json auto 2>/dev/null | while read -r line; do TYPE=$(echo "$line" | jq -r '.type') case "$TYPE" in tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;; diff --git a/gsd-orchestrator/references/answer-injection.md b/gsd-orchestrator/references/answer-injection.md index 7cd27c625..8032350bd 100644 --- a/gsd-orchestrator/references/answer-injection.md +++ b/gsd-orchestrator/references/answer-injection.md @@ -5,8 +5,8 @@ Pre-supply answers and secrets to eliminate interactive prompts during headless ## Usage ```bash -gsd headless --answers answers.json auto -gsd headless --answers answers.json new-milestone --context spec.md --auto +sf headless --answers answers.json auto +sf headless --answers answers.json new-milestone --context spec.md --auto ``` The `--answers` flag takes a path to a JSON file containing pre-supplied answers and secrets. @@ -111,9 +111,9 @@ cat > answers.json << 'EOF' EOF # Run with pre-supplied answers -gsd headless --answers answers.json --output-format json auto 2>/dev/null +sf headless --answers answers.json --output-format json auto 2>/dev/null # Parse result -RESULT=$(gsd headless --answers answers.json --output-format json next 2>/dev/null) +RESULT=$(sf headless --answers answers.json --output-format json next 2>/dev/null) echo "$RESULT" | jq '{status: .status, cost: .cost.total}' ``` diff --git a/gsd-orchestrator/references/commands.md b/gsd-orchestrator/references/commands.md index 767ed674f..a92b6e294 100644 --- a/gsd-orchestrator/references/commands.md +++ b/gsd-orchestrator/references/commands.md @@ -1,10 +1,10 @@ # SF Commands Reference -All commands run as subprocesses via `gsd headless [flags] [command] [args...]`. +All commands run as subprocesses via `sf headless [flags] [command] [args...]`. ## Global Flags -These flags apply to any `gsd headless` invocation: +These flags apply to any `sf headless` invocation: | Flag | Description | |------|-------------| @@ -36,7 +36,7 @@ These flags apply to any `gsd headless` invocation: Autonomous mode — loop through all pending units until milestone complete or blocked. ```bash -gsd headless --output-format json auto +sf headless --output-format json auto ``` ### `next` @@ -44,7 +44,7 @@ gsd headless --output-format json auto Step mode — execute exactly one unit (task/slice/milestone step), then exit. Recommended for orchestrators that need decision points between steps. ```bash -gsd headless --output-format json next +sf headless --output-format json next ``` ### `new-milestone` @@ -52,10 +52,10 @@ gsd headless --output-format json next Create a milestone from a specification document. ```bash -gsd headless new-milestone --context spec.md -gsd headless new-milestone --context spec.md --auto -gsd headless new-milestone --context-text "Build a REST API" --auto -cat spec.md | gsd headless new-milestone --context - --auto +sf headless new-milestone --context spec.md +sf headless new-milestone --context spec.md --auto +sf headless new-milestone --context-text "Build a REST API" --auto +cat spec.md | sf headless new-milestone --context - --auto ``` Extra flags: @@ -68,13 +68,13 @@ Extra flags: Force-route to a specific phase, bypassing normal state-machine routing. ```bash -gsd headless dispatch research -gsd headless dispatch plan -gsd headless dispatch execute -gsd headless dispatch complete -gsd headless dispatch reassess -gsd headless dispatch uat -gsd headless dispatch replan +sf headless dispatch research +sf headless dispatch plan +sf headless dispatch execute +sf headless dispatch complete +sf headless dispatch reassess +sf headless dispatch uat +sf headless dispatch replan ``` ### `discuss` @@ -82,7 +82,7 @@ gsd headless dispatch replan Start guided milestone/slice discussion. ```bash -gsd headless discuss +sf headless discuss ``` ### `stop` @@ -90,7 +90,7 @@ gsd headless discuss Stop auto-mode gracefully. ```bash -gsd headless stop +sf headless stop ``` ### `pause` @@ -98,7 +98,7 @@ gsd headless stop Pause auto-mode (preserves state, resumable). ```bash -gsd headless pause +sf headless pause ``` ## State Inspection @@ -108,10 +108,10 @@ gsd headless pause **Instant JSON snapshot** — state, next dispatch, parallel costs. No LLM, ~50ms. The recommended way for orchestrators to inspect state. ```bash -gsd headless query -gsd headless query | jq '.state.phase' -gsd headless query | jq '.next' -gsd headless query | jq '.cost.total' +sf headless query +sf headless query | jq '.state.phase' +sf headless query | jq '.next' +sf headless query | jq '.cost.total' ``` ### `status` @@ -119,7 +119,7 @@ gsd headless query | jq '.cost.total' Progress dashboard (TUI overlay — useful interactively, not for parsing). ```bash -gsd headless status +sf headless status ``` ### `history` @@ -127,7 +127,7 @@ gsd headless status Execution history. Supports `--cost`, `--phase`, `--model`, and `limit` arguments. ```bash -gsd headless history +sf headless history ``` ## Unit Control @@ -137,7 +137,7 @@ gsd headless history Prevent a unit from auto-mode dispatch. ```bash -gsd headless skip +sf headless skip ``` ### `undo` @@ -145,8 +145,8 @@ gsd headless skip Revert last completed unit. Use `--force` to bypass confirmation. ```bash -gsd headless undo -gsd headless undo --force +sf headless undo +sf headless undo --force ``` ### `steer ` @@ -154,7 +154,7 @@ gsd headless undo --force Hard-steer plan documents during execution. Useful for mid-course corrections. ```bash -gsd headless steer "Skip the blocked dependency, use mock instead" +sf headless steer "Skip the blocked dependency, use mock instead" ``` ### `queue` @@ -162,7 +162,7 @@ gsd headless steer "Skip the blocked dependency, use mock instead" Queue and reorder future milestones. ```bash -gsd headless queue +sf headless queue ``` ## Configuration & Health @@ -172,7 +172,7 @@ gsd headless queue Runtime health checks with auto-fix. ```bash -gsd headless doctor +sf headless doctor ``` ### `prefs` @@ -180,7 +180,7 @@ gsd headless doctor Manage preferences (global/project/status/wizard/setup). ```bash -gsd headless prefs +sf headless prefs ``` ### `knowledge ` @@ -188,7 +188,7 @@ gsd headless prefs Add persistent project knowledge. ```bash -gsd headless knowledge "Always use UTC timestamps in API responses" +sf headless knowledge "Always use UTC timestamps in API responses" ``` ## Phases diff --git a/gsd-orchestrator/references/json-result.md b/gsd-orchestrator/references/json-result.md index bb30a1d31..04adf33ce 100644 --- a/gsd-orchestrator/references/json-result.md +++ b/gsd-orchestrator/references/json-result.md @@ -6,7 +6,7 @@ When using `--output-format json`, SF collects events silently and emits a singl ```bash # Capture the JSON result -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) EXIT=$? # Parse fields with jq @@ -61,7 +61,7 @@ echo "$RESULT" | jq '.nextAction' ### Decision-Making After Each Step ```bash -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) EXIT=$? case $EXIT in @@ -76,7 +76,7 @@ case $EXIT in ;; 10) echo "Blocked — needs intervention" - gsd headless query | jq '.state' + sf headless query | jq '.state' ;; 11) echo "Cancelled" @@ -87,7 +87,7 @@ esac ### Cost Tracking ```bash -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) COST=$(echo "$RESULT" | jq -r '.cost.total') INPUT=$(echo "$RESULT" | jq -r '.cost.input_tokens') @@ -100,17 +100,17 @@ echo "Cost: \$$COST (${INPUT} in / ${OUTPUT} out)" ```bash # First run — capture session ID -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) SESSION_ID=$(echo "$RESULT" | jq -r '.sessionId') # Resume the same session later -gsd headless --resume "$SESSION_ID" --output-format json next 2>/dev/null +sf headless --resume "$SESSION_ID" --output-format json next 2>/dev/null ``` ### Artifact Collection ```bash -RESULT=$(gsd headless --output-format json auto 2>/dev/null) +RESULT=$(sf headless --output-format json auto 2>/dev/null) # List files created/modified echo "$RESULT" | jq -r '.artifacts[]?' @@ -140,7 +140,7 @@ echo "$RESULT" | jq -r '.commits[]?' "phase": "executing", "nextAction": "dispatch", "artifacts": [ - ".gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md" + ".sf/milestones/M001/slices/S01/tasks/T01-SUMMARY.md" ], "commits": [ "a1b2c3d" @@ -154,9 +154,9 @@ The `HeadlessJsonResult` captures what happened during a session. Use `query` fo ```bash # What happened in this step? -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) echo "$RESULT" | jq '{status, cost: .cost.total, phase}' # What's the overall project state now? -gsd headless query | jq '{phase: .state.phase, progress: .state.progress, totalCost: .cost.total}' +sf headless query | jq '{phase: .state.phase, progress: .state.progress, totalCost: .cost.total}' ``` diff --git a/gsd-orchestrator/workflows/build-from-spec.md b/gsd-orchestrator/workflows/build-from-spec.md index 244384e19..9552fa7b0 100644 --- a/gsd-orchestrator/workflows/build-from-spec.md +++ b/gsd-orchestrator/workflows/build-from-spec.md @@ -4,7 +4,7 @@ End-to-end workflow: take a product idea or specification, produce working softw ## Prerequisites -- `gsd` CLI installed (`npm install -g sf-run`) +- `sf` CLI installed (`npm install -g sf-run`) - A directory for the project (can be empty) - Git initialized in the directory @@ -55,7 +55,7 @@ SPEC **Fire-and-forget (simplest — SF does everything):** ```bash cd "$PROJECT_DIR" -RESULT=$(gsd headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +RESULT=$(sf headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) EXIT=$? ``` @@ -69,7 +69,7 @@ EXIT=$? **For CI or ecosystem runs (no user config):** ```bash -RESULT=$(gsd headless --bare --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +RESULT=$(sf headless --bare --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) EXIT=$? ``` @@ -85,7 +85,7 @@ case $EXIT in echo "Build complete: $STATUS, cost: \$$COST, commits: $COMMITS" # Inspect what was built - gsd headless query | jq '.state.progress' + sf headless query | jq '.state.progress' # Check the actual files ls -la "$PROJECT_DIR" @@ -96,12 +96,12 @@ case $EXIT in echo "$RESULT" | jq '{status: .status, phase: .phase}' # Check state for details - gsd headless query | jq '.state' + sf headless query | jq '.state' ;; 10) # Blocked — needs intervention echo "Build blocked — needs human input" - gsd headless query | jq '{phase: .state.phase, blockers: .state.blockers}' + sf headless query | jq '{phase: .state.phase, blockers: .state.blockers}' # Options: steer, supply answers, or escalate # See workflows/monitor-and-poll.md for blocker handling @@ -120,7 +120,7 @@ After a successful build, verify the output: cd "$PROJECT_DIR" # Check project state -gsd headless query | jq '{ +sf headless query | jq '{ phase: .state.phase, progress: .state.progress, cost: .cost.total @@ -168,7 +168,7 @@ Build a REST API for managing todo items using Node.js and Express. SPEC # 3. Launch -RESULT=$(gsd headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +RESULT=$(sf headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) EXIT=$? # 4. Report @@ -176,7 +176,7 @@ if [ $EXIT -eq 0 ]; then COST=$(echo "$RESULT" | jq -r '.cost.total') echo "Build complete (\$$COST)" echo "Files created:" - find . -not -path './.gsd/*' -not -path './.git/*' -type f + find . -not -path './.sf/*' -not -path './.git/*' -type f else echo "Build failed (exit $EXIT)" echo "$RESULT" | jq . diff --git a/gsd-orchestrator/workflows/monitor-and-poll.md b/gsd-orchestrator/workflows/monitor-and-poll.md index bfa4e884b..ffff137e4 100644 --- a/gsd-orchestrator/workflows/monitor-and-poll.md +++ b/gsd-orchestrator/workflows/monitor-and-poll.md @@ -8,14 +8,14 @@ The `query` command is your primary monitoring tool. It's instant (~50ms), costs ```bash cd /path/to/project -gsd headless query +sf headless query ``` ### Key fields to inspect ```bash # Overall status -gsd headless query | jq '{ +sf headless query | jq '{ phase: .state.phase, milestone: .state.activeMilestone.id, slice: .state.activeSlice.id, @@ -25,11 +25,11 @@ gsd headless query | jq '{ }' # What should happen next -gsd headless query | jq '.next' +sf headless query | jq '.next' # Returns: { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" } # Is it done? -gsd headless query | jq '.state.phase' +sf headless query | jq '.state.phase' # "complete" = done, "blocked" = needs you, anything else = in progress ``` @@ -59,10 +59,10 @@ When exit code is `10` or phase is `blocked`: ```bash # 1. Understand the blocker -gsd headless query | jq '{phase: .state.phase, blockers: .state.blockers, nextAction: .state.nextAction}' +sf headless query | jq '{phase: .state.phase, blockers: .state.blockers, nextAction: .state.nextAction}' # 2. Option A: Steer around it -gsd headless steer "Skip the database dependency, use in-memory storage instead" +sf headless steer "Skip the database dependency, use in-memory storage instead" # 3. Option B: Supply pre-built answers cat > fix.json << 'EOF' @@ -71,13 +71,13 @@ cat > fix.json << 'EOF' "defaults": { "strategy": "first_option" } } EOF -gsd headless --answers fix.json auto +sf headless --answers fix.json auto # 4. Option C: Force a specific phase -gsd headless dispatch replan +sf headless dispatch replan # 5. Option D: Escalate to user -echo "SF build blocked. Phase: $(gsd headless query | jq -r '.state.phase')" +echo "SF build blocked. Phase: $(sf headless query | jq -r '.state.phase')" echo "Manual intervention required." ``` @@ -85,13 +85,13 @@ echo "Manual intervention required." ```bash # Current cumulative cost -gsd headless query | jq '.cost.total' +sf headless query | jq '.cost.total' # Per-worker breakdown -gsd headless query | jq '.cost.workers' +sf headless query | jq '.cost.workers' # After a step (from HeadlessJsonResult) -RESULT=$(gsd headless --output-format json next 2>/dev/null) +RESULT=$(sf headless --output-format json next 2>/dev/null) echo "$RESULT" | jq '.cost' ``` @@ -101,11 +101,11 @@ echo "$RESULT" | jq '.cost' MAX_BUDGET=15.00 check_budget() { - TOTAL=$(gsd headless query | jq -r '.cost.total') + TOTAL=$(sf headless query | jq -r '.cost.total') OVER=$(echo "$TOTAL > $MAX_BUDGET" | bc -l) if [ "$OVER" = "1" ]; then echo "Budget exceeded: \$$TOTAL > \$$MAX_BUDGET" - gsd headless stop + sf headless stop return 1 fi return 0 @@ -120,7 +120,7 @@ For agents that need to periodically check on a build: cd /path/to/project poll_project() { - STATE=$(gsd headless query 2>/dev/null) + STATE=$(sf headless query 2>/dev/null) if [ -z "$STATE" ]; then echo "NO_PROJECT" return @@ -154,13 +154,13 @@ If a build was interrupted or you need to continue: cd /path/to/project # Check current state -gsd headless query | jq '.state.phase' +sf headless query | jq '.state.phase' # Resume from where it left off -gsd headless --output-format json auto 2>/dev/null +sf headless --output-format json auto 2>/dev/null # Or resume a specific session -gsd headless --resume "$SESSION_ID" --output-format json auto 2>/dev/null +sf headless --resume "$SESSION_ID" --output-format json auto 2>/dev/null ``` ## Reading Build Artifacts @@ -171,16 +171,16 @@ After completion, inspect what SF produced: cd /path/to/project # Project summary -cat .gsd/PROJECT.md +cat .sf/PROJECT.md # What was decided -cat .gsd/DECISIONS.md +cat .sf/DECISIONS.md # Requirements and their validation status -cat .gsd/REQUIREMENTS.md +cat .sf/REQUIREMENTS.md # Milestone summary -cat .gsd/milestones/M001-*/M001-*-SUMMARY.md 2>/dev/null +cat .sf/milestones/M001-*/M001-*-SUMMARY.md 2>/dev/null # Git history (SF commits per-slice) git log --oneline diff --git a/gsd-orchestrator/workflows/step-by-step.md b/gsd-orchestrator/workflows/step-by-step.md index 5d25198d2..b9f9eb1e6 100644 --- a/gsd-orchestrator/workflows/step-by-step.md +++ b/gsd-orchestrator/workflows/step-by-step.md @@ -20,7 +20,7 @@ TOTAL_COST=0 while true; do # Run one unit - RESULT=$(gsd headless --output-format json next 2>/dev/null) + RESULT=$(sf headless --output-format json next 2>/dev/null) EXIT=$? # Parse result @@ -38,7 +38,7 @@ while true; do ;; 10) echo "Blocked — needs intervention" - gsd headless query | jq '.state' + sf headless query | jq '.state' break ;; 11) @@ -48,24 +48,24 @@ while true; do esac # Check if milestone complete - CURRENT_PHASE=$(gsd headless query | jq -r '.state.phase') + CURRENT_PHASE=$(sf headless query | jq -r '.state.phase') if [ "$CURRENT_PHASE" = "complete" ]; then - TOTAL_COST=$(gsd headless query | jq -r '.cost.total') + TOTAL_COST=$(sf headless query | jq -r '.cost.total') echo "Milestone complete. Total cost: \$$TOTAL_COST" break fi # Budget check - TOTAL_COST=$(gsd headless query | jq -r '.cost.total') + TOTAL_COST=$(sf headless query | jq -r '.cost.total') OVER=$(echo "$TOTAL_COST > $MAX_BUDGET" | bc -l) if [ "$OVER" = "1" ]; then echo "Budget limit (\$$MAX_BUDGET) exceeded at \$$TOTAL_COST" - gsd headless stop + sf headless stop break fi # Progress report - PROGRESS=$(gsd headless query | jq -r '"\(.state.progress.tasks.done)/\(.state.progress.tasks.total) tasks"') + PROGRESS=$(sf headless query | jq -r '"\(.state.progress.tasks.done)/\(.state.progress.tasks.total) tasks"') echo "Step done ($STATUS). Phase: $CURRENT_PHASE, Progress: $PROGRESS, Cost: \$$TOTAL_COST" done ``` @@ -85,7 +85,7 @@ cat > spec.md << 'SPEC' SPEC # 3. Create the milestone (planning only, no execution) -RESULT=$(gsd headless --output-format json --context spec.md new-milestone 2>/dev/null) +RESULT=$(sf headless --output-format json --context spec.md new-milestone 2>/dev/null) EXIT=$? if [ $EXIT -ne 0 ]; then @@ -100,13 +100,13 @@ echo "Milestone created. Starting execution..." STEP=0 while true; do STEP=$((STEP + 1)) - RESULT=$(gsd headless --output-format json next 2>/dev/null) + RESULT=$(sf headless --output-format json next 2>/dev/null) EXIT=$? [ $EXIT -ne 0 ] && break - PHASE=$(gsd headless query | jq -r '.state.phase') - COST=$(gsd headless query | jq -r '.cost.total') + PHASE=$(sf headless query | jq -r '.state.phase') + COST=$(sf headless query | jq -r '.cost.total') echo "Step $STEP complete. Phase: $PHASE, Cost: \$$COST" @@ -124,33 +124,33 @@ If you detect the build going in the wrong direction: ```bash # Check what's happening -gsd headless query | jq '{phase: .state.phase, task: .state.activeTask}' +sf headless query | jq '{phase: .state.phase, task: .state.activeTask}' # Redirect -gsd headless steer "Use SQLite instead of PostgreSQL for storage" +sf headless steer "Use SQLite instead of PostgreSQL for storage" # Continue -gsd headless --output-format json next 2>/dev/null +sf headless --output-format json next 2>/dev/null ``` ### Skip a stuck unit ```bash -gsd headless skip -gsd headless --output-format json next 2>/dev/null +sf headless skip +sf headless --output-format json next 2>/dev/null ``` ### Undo last completed unit ```bash -gsd headless undo --force -gsd headless --output-format json next 2>/dev/null +sf headless undo --force +sf headless --output-format json next 2>/dev/null ``` ### Force a specific phase ```bash -gsd headless dispatch replan # Re-plan the current slice -gsd headless dispatch execute # Skip to execution -gsd headless dispatch uat # Jump to user acceptance testing +sf headless dispatch replan # Re-plan the current slice +sf headless dispatch execute # Skip to execution +sf headless dispatch uat # Jump to user acceptance testing ``` diff --git a/native/crates/engine/src/gsd_parser.rs b/native/crates/engine/src/forge_parser.rs similarity index 100% rename from native/crates/engine/src/gsd_parser.rs rename to native/crates/engine/src/forge_parser.rs diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 513b33276..2620e29d6 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -1,4 +1,4 @@ -# @gsd-build/mcp-server +# @sf-build/mcp-server MCP server exposing SF orchestration tools for Claude Code, Cursor, and other MCP-compatible clients. @@ -13,14 +13,14 @@ This package now exposes two tool surfaces: ## Installation ```bash -npm install @gsd-build/mcp-server +npm install @sf-build/mcp-server ``` Or with the monorepo workspace: ```bash # Already available as a workspace package -npx gsd-mcp-server +npx sf-mcp-server ``` ## Configuration @@ -32,11 +32,11 @@ Add to your project's `.mcp.json`: ```json { "mcpServers": { - "gsd": { + "sf": { "command": "npx", - "args": ["gsd-mcp-server"], + "args": ["sf-mcp-server"], "env": { - "SF_CLI_PATH": "/path/to/gsd" + "SF_CLI_PATH": "/path/to/sf" } } } @@ -48,8 +48,8 @@ Or if installed globally: ```json { "mcpServers": { - "gsd": { - "command": "gsd-mcp-server" + "sf": { + "command": "sf-mcp-server" } } } @@ -62,11 +62,11 @@ Add to `.cursor/mcp.json`: ```json { "mcpServers": { - "gsd": { + "sf": { "command": "npx", - "args": ["gsd-mcp-server"], + "args": ["sf-mcp-server"], "env": { - "SF_CLI_PATH": "/path/to/gsd" + "SF_CLI_PATH": "/path/to/sf" } } } @@ -133,7 +133,7 @@ Start a SF auto-mode session for a project directory. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `projectDir` | `string` | ✅ | Absolute path to the project directory | -| `command` | `string` | | Command to send (default: `"/gsd auto"`) | +| `command` | `string` | | Command to send (default: `"/sf auto"`) | | `model` | `string` | | Model ID override | | `bare` | `boolean` | | Run in bare mode (skip user config) | @@ -231,21 +231,21 @@ Resolve a pending blocker in a session by sending a response to the blocked UI r | Variable | Description | |----------|-------------| -| `SF_CLI_PATH` | Absolute path to the SF CLI binary. If not set, the server resolves `gsd` via `which`. | +| `SF_CLI_PATH` | Absolute path to the SF CLI binary. If not set, the server resolves `sf` via `which`. | | `SF_WORKFLOW_EXECUTORS_MODULE` | Optional absolute path or `file:` URL for the shared SF workflow executor module used by workflow mutation tools. | -The server also hydrates supported model-provider and tool credentials from `~/.gsd/agent/auth.json` on startup. Keys saved through `/gsd config` or `/gsd keys` become available to the MCP server process automatically, and any explicitly-set environment variable still wins. +The server also hydrates supported model-provider and tool credentials from `~/.sf/agent/auth.json` on startup. Keys saved through `/sf config` or `/sf keys` become available to the MCP server process automatically, and any explicitly-set environment variable still wins. ## Architecture ``` ┌─────────────────┐ stdio ┌──────────────────┐ -│ MCP Client │ ◄────────────► │ @gsd-build/mcp-server │ +│ MCP Client │ ◄────────────► │ @sf-build/mcp-server │ │ (Claude Code, │ JSON-RPC │ │ │ Cursor, etc.) │ │ SessionManager │ └─────────────────┘ │ │ │ │ ▼ │ - │ @gsd-build/rpc-client │ + │ @sf-build/rpc-client │ │ │ │ │ ▼ │ │ SF CLI (child │ @@ -253,9 +253,9 @@ The server also hydrates supported model-provider and tool credentials from `~/. └──────────────────┘ ``` -- **@gsd-build/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations. +- **@sf-build/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations. - **SessionManager** — Manages RpcClient lifecycle. One session per project directory. Tracks events in a ring buffer (last 50), detects blockers, accumulates cost. -- **@gsd-build/rpc-client** — Low-level RPC client that spawns and communicates with the SF CLI process via JSON-RPC over stdio. +- **@sf-build/rpc-client** — Low-level RPC client that spawns and communicates with the SF CLI process via JSON-RPC over stdio. ## License diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index 4893d2ec3..5565b964e 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/sf/gsd-db.ts"; +import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/sf/sf-db.ts"; import { registerWorkflowTools, WORKFLOW_TOOL_NAMES } from "./workflow-tools.ts"; function makeTmpBase(): string { diff --git a/packages/rpc-client/README.md b/packages/rpc-client/README.md index 520c14692..8799511ca 100644 --- a/packages/rpc-client/README.md +++ b/packages/rpc-client/README.md @@ -1,4 +1,4 @@ -# @gsd-build/rpc-client +# @sf-build/rpc-client Standalone RPC client SDK for SF. Spawn the agent process, perform a v2 protocol handshake, send commands, and consume typed events via an async generator — all in a few lines of TypeScript. @@ -7,13 +7,13 @@ Zero internal dependencies. Ships its own inlined types. ## Installation ```bash -npm install @gsd-build/rpc-client +npm install @sf-build/rpc-client ``` ## Quick Start ```typescript -import { RpcClient } from '@gsd-build/rpc-client'; +import { RpcClient } from '@sf-build/rpc-client'; const client = new RpcClient({ cwd: process.cwd() }); await client.start(); @@ -117,7 +117,7 @@ import type { SessionStats, SdkAgentEvent, RpcClientOptions, -} from '@gsd-build/rpc-client'; +} from '@sf-build/rpc-client'; ``` ## License diff --git a/scripts/parallel-monitor.mjs b/scripts/parallel-monitor.mjs index 7184c84ca..a90cf5ec7 100755 --- a/scripts/parallel-monitor.mjs +++ b/scripts/parallel-monitor.mjs @@ -337,6 +337,9 @@ function respawnWorker(mid) { SF_MILESTONE_LOCK: mid, SF_PROJECT_ROOT: PROJECT_ROOT, SF_PARALLEL_WORKER: '1', + GSD_MILESTONE_LOCK: mid, + GSD_PROJECT_ROOT: PROJECT_ROOT, + GSD_PARALLEL_WORKER: '1', }, stdio: ['ignore', stdoutFd, stderrFd], windowsHide: true, diff --git a/scripts/postinstall.js b/scripts/postinstall.js index f483e89e5..6ae9ef0c4 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -19,12 +19,16 @@ const RTK_SKIP = process.env.SF_SKIP_RTK_INSTALL === '1' || process.env.SF_SKIP_RTK_INSTALL === 'true' || process.env.SF_RTK_DISABLED === '1' || - process.env.SF_RTK_DISABLED === 'true' + process.env.SF_RTK_DISABLED === 'true' || + process.env.GSD_SKIP_RTK_INSTALL === '1' || + process.env.GSD_SKIP_RTK_INSTALL === 'true' || + process.env.GSD_RTK_DISABLED === '1' || + process.env.GSD_RTK_DISABLED === 'true' const RTK_VERSION = '0.33.1' const RTK_REPO = 'rtk-ai/rtk' const RTK_ENV = { ...process.env, RTK_TELEMETRY_DISABLED: '1' } -const managedBinDir = join(process.env.SF_HOME || process.env.SF_HOME || join(homedir(), '.gsd'), 'agent', 'bin') +const managedBinDir = join(process.env.SF_HOME || process.env.GSD_HOME || join(homedir(), '.gsd'), 'agent', 'bin') const managedBinaryPath = join(managedBinDir, platform() === 'win32' ? 'rtk.exe' : 'rtk') function run(cmd) { @@ -69,7 +73,7 @@ function sha256File(path) { } async function downloadToFile(url, destination) { - const response = await fetch(url, { headers: { 'User-Agent': 'sf-run-postinstall' } }) + const response = await fetch(url, { headers: { 'User-Agent': 'sf-pi-postinstall' } }) if (!response.ok) { throw new Error(`download failed (${response.status}) for ${url}`) } @@ -121,7 +125,7 @@ async function ensureRtkInstalled() { try { const checksumsResponse = await fetch(`${releaseBase}/checksums.txt`, { - headers: { 'User-Agent': 'sf-run-postinstall' }, + headers: { 'User-Agent': 'sf-pi-postinstall' }, }) if (!checksumsResponse.ok) { throw new Error(`failed to fetch RTK checksums (${checksumsResponse.status})`) diff --git a/scripts/recover-gsd-1364.ps1 b/scripts/recover-gsd-1364.ps1 index e85ed03cf..1e6aacdc5 100644 --- a/scripts/recover-gsd-1364.ps1 +++ b/scripts/recover-gsd-1364.ps1 @@ -103,15 +103,15 @@ if ($DryRun) { Write-Section "── Step 1: Detect .gsd/ directory ─────────────────────────────────" -$gsdDir = Join-Path $repoRoot '.gsd' +$sfDir = Join-Path $repoRoot '.gsd' $GsdIsSymlink = $false -if (-not (Test-Path $gsdDir)) { +if (-not (Test-Path $sfDir)) { Write-Ok ".gsd/ does not exist in this repo — not affected." exit 0 } -if (Test-ReparsePoint $gsdDir) { +if (Test-ReparsePoint $sfDir) { # Scenario C: migration succeeded (symlink/junction in place) but git index was never # cleaned — tracked .gsd/* files still appear as deleted through the reparse point. $GsdIsSymlink = $true diff --git a/scripts/rtk-benchmark.mjs b/scripts/rtk-benchmark.mjs index 6ab09446f..ba1caa312 100644 --- a/scripts/rtk-benchmark.mjs +++ b/scripts/rtk-benchmark.mjs @@ -108,7 +108,7 @@ function renderMarkdown({ summary, history, binaryPath }) { function main() { const outputIndex = process.argv.indexOf('--output') const outputPath = outputIndex !== -1 ? process.argv[outputIndex + 1] : null - const binaryPath = process.env.SF_RTK_PATH || getManagedRtkPath() + const binaryPath = process.env.SF_RTK_PATH || process.env.GSD_RTK_PATH || getManagedRtkPath() if (!binaryPath) { throw new Error('RTK binary path not resolved') diff --git a/scripts/verify-s03.sh b/scripts/verify-s03.sh index 81143facf..240c12e11 100755 --- a/scripts/verify-s03.sh +++ b/scripts/verify-s03.sh @@ -132,6 +132,7 @@ tmp6=$(mktemp) env -i HOME="$HOME" PATH="$PATH" \ ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ SF_TEST_AUTH_PATH="$tmp_auth" \ + GSD_TEST_AUTH_PATH="$tmp_auth" \ node -e " import('./dist/app-paths.js').then(async (paths) => { // Override authFilePath for test diff --git a/src/cli.ts b/src/cli.ts index c5b660bc7..76ea1bb92 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,7 +32,7 @@ import { stopWebMode } from './web-mode.js' import { getProjectSessionsDir } from './project-sessions.js' import { markStartup, printStartupTimings } from './startup-timings.js' import { bootstrapRtk, SF_RTK_DISABLED_ENV, SF_RTK_DISABLED_ENV } from './rtk.js' -import { loadEffectiveGSDPreferences } from './resources/extensions/sf/preferences.js' +import { loadEffectiveSFPreferences } from './resources/extensions/sf/preferences.js' // --------------------------------------------------------------------------- // V8 compile cache — Node 22+ can cache compiled bytecode across runs, @@ -148,7 +148,7 @@ async function doRtkBootstrap(): Promise { // Honor SF_RTK_DISABLED (or SF_RTK_DISABLED) if already explicitly set in the environment // (env var takes precedence over preferences for manual override). if (!process.env[SF_RTK_DISABLED_ENV] && !process.env[SF_RTK_DISABLED_ENV]) { - const prefs = loadEffectiveGSDPreferences() + const prefs = loadEffectiveSFPreferences() const rtkEnabled = prefs?.preferences.experimental?.rtk === true if (!rtkEnabled) { process.env[SF_RTK_DISABLED_ENV] = '1' diff --git a/src/resources/SF-WORKFLOW.md b/src/resources/SF-WORKFLOW.md index 433808a91..5a4c276ad 100644 --- a/src/resources/SF-WORKFLOW.md +++ b/src/resources/SF-WORKFLOW.md @@ -2,9 +2,9 @@ > This document teaches you how to operate the SF planning methodology manually using files on disk. > -> **When to read this:** At the start of any session working on SF-managed work, or when loaded by `/gsd`. +> **When to read this:** At the start of any session working on SF-managed work, or when loaded by `/sf`. > -> **After reading this, always read `.gsd/STATE.md` to find out what's next.** +> **After reading this, always read `.sf/STATE.md` to find out what's next.** > If the milestone has a `M###-CONTEXT.md`, read that too. If the active slice has an `S##-CONTEXT.md`, read that as well — these files contain project-specific decisions, reference paths, and implementation guidance that this generic methodology doc does not. --- @@ -13,12 +13,12 @@ Read these files in order and act on what they say: -1. **`.gsd/STATE.md`** — Where are we? What's the next action? -2. **`.gsd/milestones//M###-ROADMAP.md`** — What's the plan? Which slices are done? (`STATE.md` tells you which milestone is active) -3. **`.gsd/milestones//M###-CONTEXT.md`** — Milestone-level project decisions, reference paths, constraints. Read this before doing implementation work. +1. **`.sf/STATE.md`** — Where are we? What's the next action? +2. **`.sf/milestones//M###-ROADMAP.md`** — What's the plan? Which slices are done? (`STATE.md` tells you which milestone is active) +3. **`.sf/milestones//M###-CONTEXT.md`** — Milestone-level project decisions, reference paths, constraints. Read this before doing implementation work. 4. If a slice is active and has one, read **`S##-CONTEXT.md`** — Slice-specific decisions and constraints. 5. If a slice is active, read its **`S##-PLAN.md`** — Which tasks exist? Which are done? -6. If `.gsd/CODEBASE.md` exists, skim it for fast structural orientation before broad code exploration. +6. If `.sf/CODEBASE.md` exists, skim it for fast structural orientation before broad code exploration. 7. If a task was interrupted, check for **`continue.md`** in the active slice directory — Resume from there. Then do the thing `STATE.md` says to do next. @@ -39,10 +39,10 @@ Milestone → a shippable version (4-10 slices) ## File Locations -All artifacts live in `.gsd/` at the project root: +All artifacts live in `.sf/` at the project root: ``` -.gsd/ +.sf/ STATE.md # Dashboard — always read first (derived cache; runtime, gitignored) DECISIONS.md # Append-only decisions register CODEBASE.md # Generated codebase map cache (auto-refreshed by SF) @@ -330,7 +330,7 @@ The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expens **Produces:** `S##-PLAN.md` + individual `T01-PLAN.md` files. **For a milestone (roadmap):** -1. Read `M###-CONTEXT.md`, `M###-RESEARCH.md`, and `.gsd/DECISIONS.md` if they exist. +1. Read `M###-CONTEXT.md`, `M###-RESEARCH.md`, and `.sf/DECISIONS.md` if they exist. 2. Decompose the vision into 4-10 demoable vertical slices. 3. Order by risk (high-risk first to validate feasibility early). 4. Write `M###-ROADMAP.md` with checkboxes, risk levels, dependencies, demo sentences. @@ -338,7 +338,7 @@ The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expens **For a slice (task decomposition):** 1. Read the slice's entry in `M###-ROADMAP.md` **and its boundary map section** — know what interfaces this slice must produce and consume. -2. Read `M###-CONTEXT.md`, `S##-CONTEXT.md`, `M###-RESEARCH.md`, `S##-RESEARCH.md`, and `.gsd/DECISIONS.md` if they exist for this slice. +2. Read `M###-CONTEXT.md`, `S##-CONTEXT.md`, `M###-RESEARCH.md`, `S##-RESEARCH.md`, and `.sf/DECISIONS.md` if they exist for this slice. 3. Read summaries from dependency slices (check `depends:[]` in roadmap). 4. Verify that upstream slices' actual outputs match what the boundary map says this slice consumes. If they diverge, update the boundary map. 5. Decompose into 1-7 tasks, each fitting one context window. @@ -355,7 +355,7 @@ The **Don't Hand-Roll** and **Common Pitfalls** sections prevent the most expens 1. Read the task's `T##-PLAN.md`. 2. Read relevant summaries from prior tasks (for context on what's already built). 3. Execute each step. Mark progress with `[DONE:n]` in responses. -4. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. +4. If you made an architectural, pattern, or library decision, append it to `.sf/DECISIONS.md`. 5. If interrupted or context is getting full, write `continue.md` (see below). ### Phase 5: Verify @@ -424,7 +424,7 @@ key_decisions: patterns_established: - "Pattern name and where it lives" drill_down_paths: - - .gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md + - .sf/milestones/M001/slices/S01/tasks/T01-PLAN.md duration: 15min verification_result: pass completed_at: 2026-03-07T16:00:00Z @@ -448,7 +448,7 @@ What differed from the plan and why (or "None"). The one-liner must be substantive: "JWT auth with refresh rotation using jose" not "Authentication implemented." -**Slice summary:** Written when all tasks in a slice complete. Compresses all task summaries. Includes `drill_down_paths` to each task summary. During slice completion, review task summaries for `key_decisions` and ensure any significant ones are captured in `.gsd/DECISIONS.md`. +**Slice summary:** Written when all tasks in a slice complete. Compresses all task summaries. Includes `drill_down_paths` to each task summary. During slice completion, review task summaries for `key_decisions` and ensure any significant ones are captured in `.sf/DECISIONS.md`. **Milestone summary:** Updated each time a slice completes. Compresses all slice summaries. This is what gets injected into later slice planning instead of loading many individual summaries. @@ -548,7 +548,7 @@ If files disagree, **pause and surface to the user**: ### Branch Lifecycle -1. **Slice starts** → create branch `gsd/M001/S01` from main +1. **Slice starts** → create branch `sf/M001/S01` from main 2. **Per-task commits** on the branch — atomic, descriptive, bisectable 3. **Slice completes** → squash merge to main as one clean commit 4. **Branch deleted** — squash commit on main is the permanent record @@ -566,7 +566,7 @@ One commit per slice. Individually revertable. Reads like a changelog. ### What the Branch Looks Like ``` -gsd/M001/S01: +sf/M001/S01: test(S01/T03): round-trip tests passing feat(S01/T03): file writer with round-trip fidelity feat(S01/T02): markdown parser for plan files @@ -609,7 +609,7 @@ Tasks completed: |---------|-----| | Bad task | `git reset --hard HEAD~1` to previous commit on the branch | | Bad slice | `git revert ` on main | -| UAT failure after merge | Fix tasks on `gsd/M001/S01-fix` branch, squash as `fix(M001/S01): ` | +| UAT failure after merge | Fix tasks on `sf/M001/S01-fix` branch, squash as `fix(M001/S01): ` | --- @@ -638,8 +638,8 @@ These are soft caps — exceed them when genuinely needed, but don't let summari This methodology doc is generic. Project-specific guidance belongs in the milestone and slice context files: -- **`.gsd/milestones//M###-CONTEXT.md`** — milestone-level architecture decisions, reference file paths, and implementation constraints -- **`.gsd/milestones//slices/S##/S##-CONTEXT.md`** — slice-level decisions, edge cases, and narrow implementation guidance when present +- **`.sf/milestones//M###-CONTEXT.md`** — milestone-level architecture decisions, reference file paths, and implementation constraints +- **`.sf/milestones//slices/S##/S##-CONTEXT.md`** — slice-level decisions, edge cases, and narrow implementation guidance when present **Always read the active milestone's `M###-CONTEXT.md` before starting implementation work.** If the active slice also has `S##-CONTEXT.md`, read that too. These files tell you what decisions are locked, what files to reference, and how to verify your work in this specific project. @@ -647,11 +647,11 @@ This methodology doc is generic. Project-specific guidance belongs in the milest ## Checklist for a Fresh Session -1. Read `.gsd/STATE.md` — what's the next action? +1. Read `.sf/STATE.md` — what's the next action? 2. Check for `continue.md` in the active slice — is there interrupted work? 3. If resuming: read `continue.md`, delete it, pick up from "Next Action". 4. If starting fresh: read the active slice's `S##-PLAN.md`, find the next incomplete task. -5. If in a planning or research phase, read `.gsd/DECISIONS.md` — respect existing decisions. +5. If in a planning or research phase, read `.sf/DECISIONS.md` — respect existing decisions. 6. Read relevant summaries from prior tasks/slices for context. 7. Do the work. 8. Verify the must-haves. @@ -663,6 +663,6 @@ This methodology doc is generic. Project-specific guidance belongs in the milest If you sense context pressure (many files read, long execution, lots of tool output): -1. **If mid-task:** Write `continue.md` with exact resume state. Tell the user: "Context is getting full. I've saved progress to continue.md. Start a new session and run `/gsd` to pick up where you left off, or `/gsd auto` to resume in auto-execution mode." +1. **If mid-task:** Write `continue.md` with exact resume state. Tell the user: "Context is getting full. I've saved progress to continue.md. Start a new session and run `/sf` to pick up where you left off, or `/sf auto` to resume in auto-execution mode." 2. **If between tasks:** Just update `STATE.md` with the next action. No continue file needed — the next session will read STATE.md and pick up the next task cleanly. 3. **Don't fight it.** The whole system is designed for this. A fresh session with the right files loaded is better than a stale session with degraded reasoning. diff --git a/src/resources/agents/worker.md b/src/resources/agents/worker.md index 199d35a23..e15ee1ee3 100644 --- a/src/resources/agents/worker.md +++ b/src/resources/agents/worker.md @@ -8,7 +8,7 @@ You are a worker agent with full capabilities. You operate in an isolated contex Work autonomously to complete the assigned task. Use all available tools as needed, with one important restriction: - Do **not** spawn subagents or act as an orchestrator unless the parent task explicitly instructs you to do so. -- If the task looks like SF orchestration, planning, scouting, parallel dispatch, or review routing, stop and report that the caller should use the appropriate specialist agent instead (for example: `gsd-worker`, `gsd-scout`, `gsd-reviewer`, or the top-level orchestrator). +- If the task looks like SF orchestration, planning, scouting, parallel dispatch, or review routing, stop and report that the caller should use the appropriate specialist agent instead (for example: `sf-worker`, `sf-scout`, `sf-reviewer`, or the top-level orchestrator). - In particular, do **not** call `gsd_scout`, `subagent`, `launch_parallel_view`, or `gsd_execute_parallel` on your own initiative. Output format when finished: diff --git a/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md b/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md index f565a4925..b870f3ebb 100644 --- a/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +++ b/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md @@ -1069,7 +1069,7 @@ Make browser-tools able to emit outputs that directly support SF slice/task comp ## Why it matters -You explicitly want browser tools to power automatic verification and testing during `@agent/extensions/gsd/` use. +You explicitly want browser tools to power automatic verification and testing during `@agent/extensions/sf/` use. ## What it enables diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts deleted file mode 100644 index d0f08a436..000000000 --- a/src/resources/extensions/gsd/activity-log.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * SF Activity Log — Save raw chat sessions to .gsd/activity/ - * - * Before each context wipe in auto-mode, dumps the full session - * as JSONL. No formatting, no truncation, no information loss. - * These are debug artifacts — only read when summaries aren't enough. - * - * Diagnostic extraction is handled by session-forensics.ts. - */ - -import { writeFileSync, writeSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs"; -import { createHash } from "node:crypto"; -import { join } from "node:path"; -import { GSDError, SF_IO_ERROR } from "./errors.js"; - -const SEQ_PREFIX_RE = /^(\d+)-/; -import type { ExtensionContext } from "@sf-run/pi-coding-agent"; -import { gsdRoot } from "./paths.js"; -import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; -import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; - -interface ActivityLogState { - nextSeq: number; - lastSnapshotKeyByUnit: Map; -} - -const activityLogState = new Map(); - -/** - * Clear accumulated activity log state (#611). - * Call when auto-mode stops to prevent unbounded memory growth - * from lastSnapshotKeyByUnit maps accumulating across units. - */ -export function clearActivityLogState(): void { - activityLogState.clear(); -} - -function scanNextSequence(activityDir: string): number { - let maxSeq = 0; - try { - for (const f of readdirSync(activityDir)) { - const match = f.match(SEQ_PREFIX_RE); - if (match) maxSeq = Math.max(maxSeq, parseInt(match[1], 10)); - } - } catch (e) { - void e; /* directory not readable — start at 1 */ - return 1; - } - return maxSeq + 1; -} - -function getActivityState(activityDir: string): ActivityLogState { - let state = activityLogState.get(activityDir); - if (!state) { - state = { nextSeq: scanNextSequence(activityDir), lastSnapshotKeyByUnit: new Map() }; - activityLogState.set(activityDir, state); - } - return state; -} - -/** - * Build a lightweight dedup key from session entries without serializing - * the entire content to a string (#611). Uses entry count + hash of - * the last few entries as a fingerprint instead of hashing megabytes. - */ -function snapshotKey(unitType: string, unitId: string, entries: unknown[]): string { - const hash = createHash("sha1"); - hash.update(`${unitType}\0${unitId}\0${entries.length}\0`); - // Hash only the last 3 entries as a fingerprint — if the session grew, - // the count change alone detects it; if content changed, the tail hash catches it. - const tail = entries.slice(-3); - for (const entry of tail) { - hash.update(JSON.stringify(entry)); - } - return hash.digest("hex"); -} - -function nextActivityFilePath( - activityDir: string, - state: ActivityLogState, - unitType: string, - safeUnitId: string, -): string { - // Use O_CREAT | O_EXCL for atomic "create if absent" — no directory scan needed. - for (let attempts = 0; attempts < 1000; attempts++) { - const seq = String(state.nextSeq).padStart(3, "0"); - const filePath = join(activityDir, `${seq}-${unitType}-${safeUnitId}.jsonl`); - try { - const fd = openSync(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); - closeSync(fd); - return filePath; - } catch (err: any) { - if (err?.code === "EEXIST") { - state.nextSeq++; - continue; - } - throw err; - } - } - // Fallback: should never reach here in practice - throw new GSDError(SF_IO_ERROR, `Failed to find available activity log sequence in ${activityDir}`); -} - -export function saveActivityLog( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, -): string | null { - try { - const entries = ctx.sessionManager.getEntries(); - if (!entries || entries.length === 0) return null; - - const activityDir = join(gsdRoot(basePath), "activity"); - mkdirSync(activityDir, { recursive: true }); - - const safeUnitId = unitId.replace(/\//g, "-"); - const state = getActivityState(activityDir); - const unitKey = `${unitType}\0${safeUnitId}`; - // Use lightweight fingerprint instead of serializing all entries (#611) - const key = snapshotKey(unitType, safeUnitId, entries); - if (state.lastSnapshotKeyByUnit.get(unitKey) === key) return null; - - const filePath = nextActivityFilePath(activityDir, state, unitType, safeUnitId); - // Stream entries to disk line-by-line instead of building one massive string (#611). - // For large sessions, the single-string approach allocated hundreds of MB. - const fd = openSync(filePath, "w"); - try { - for (const entry of entries) { - writeSync(fd, JSON.stringify(entry) + "\n"); - } - } finally { - closeSync(fd); - } - state.nextSeq += 1; - state.lastSnapshotKeyByUnit.set(unitKey, key); - - if (isAuditEnvelopeEnabled()) { - emitUokAuditEvent( - basePath, - buildAuditEnvelope({ - traceId: `activity:${unitType}:${unitId}`, - turnId: unitId, - category: "execution", - type: "activity-log-saved", - payload: { - unitType, - unitId, - filePath, - entryCount: entries.length, - }, - }), - ); - } - - return filePath; - } catch (e) { - // Don't let logging failures break auto-mode - void e; - return null; - } -} - -export function pruneActivityLogs(activityDir: string, retentionDays: number): void { - try { - const files = readdirSync(activityDir); - const entries: { seq: number; filePath: string }[] = []; - for (const f of files) { - const match = f.match(SEQ_PREFIX_RE); - if (match) entries.push({ seq: parseInt(match[1], 10), filePath: join(activityDir, f) }); - } - if (entries.length === 0) return; - const maxSeq = Math.max(...entries.map(e => e.seq)); - const cutoff = Date.now() - retentionDays * 86_400_000; - for (const entry of entries) { - if (entry.seq === maxSeq) continue; // always preserve highest-seq - if (retentionDays === 0) { try { unlinkSync(entry.filePath); } catch { /* skip */ } continue; } - try { - const mtime = statSync(entry.filePath).mtimeMs; - if (Math.floor(mtime) <= cutoff) unlinkSync(entry.filePath); - } catch { /* file vanished or stat failed — skip */ } - } - } catch { /* empty dir or readdirSync failure — skip */ } -} diff --git a/src/resources/extensions/gsd/atomic-write.ts b/src/resources/extensions/gsd/atomic-write.ts deleted file mode 100644 index ba896db72..000000000 --- a/src/resources/extensions/gsd/atomic-write.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { writeFileSync, renameSync, unlinkSync, mkdirSync, promises as fs } from "node:fs"; -import { dirname } from "node:path"; -import { randomBytes } from "node:crypto"; - -const TRANSIENT_LOCK_ERROR_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); -const MAX_RENAME_ATTEMPTS = 5; -const SYNC_SLEEP_BUFFER = new SharedArrayBuffer(4); -const SYNC_SLEEP_VIEW = new Int32Array(SYNC_SLEEP_BUFFER); - -type RetryableEncoding = BufferEncoding; -type MkdirOptions = { recursive: true }; - -export interface AtomicWriteAsyncOps { - mkdir(path: string, options: MkdirOptions): Promise; - writeFile(path: string, content: string, encoding: RetryableEncoding): Promise; - rename(from: string, to: string): Promise; - unlink(path: string): Promise; - sleep(ms: number): Promise; - createTempPath?(filePath: string): string; -} - -export interface AtomicWriteSyncOps { - mkdir(path: string, options: MkdirOptions): void; - writeFile(path: string, content: string, encoding: RetryableEncoding): void; - rename(from: string, to: string): void; - unlink(path: string): void; - sleep(ms: number): void; - createTempPath?(filePath: string): string; -} - -function defaultTempPath(filePath: string): string { - return filePath + `.tmp.${randomBytes(4).toString("hex")}`; -} - -function computeRetryDelayMs(attempt: number): number { - const base = 8 * attempt; - const jitter = randomBytes(1)[0] % 5; - return base + jitter; -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function sleepSync(ms: number): void { - Atomics.wait(SYNC_SLEEP_VIEW, 0, 0, ms); -} - -function normalizeErrnoCode(error: unknown): string | undefined { - if (error && typeof error === "object" && "code" in error) { - const code = (error as { code?: unknown }).code; - return typeof code === "string" ? code : undefined; - } - return undefined; -} - -function isTransientLockError(error: unknown): boolean { - const code = normalizeErrnoCode(error); - return typeof code === "string" && TRANSIENT_LOCK_ERROR_CODES.has(code); -} - -function buildAtomicWriteError(filePath: string, attempts: number, error: unknown): Error { - const code = normalizeErrnoCode(error) ?? "UNKNOWN"; - const message = error instanceof Error ? error.message : String(error); - const wrapped = new Error( - `Atomic write to ${filePath} failed after ${attempts} attempts (last error code: ${code}): ${message}`, - ) as NodeJS.ErrnoException; - wrapped.code = code; - if (error instanceof Error && "stack" in error && error.stack) { - wrapped.stack = error.stack; - } - return wrapped; -} - -async function cleanupTempFileAsync(tmpPath: string, ops: AtomicWriteAsyncOps): Promise { - try { - await ops.unlink(tmpPath); - } catch { - // Best-effort cleanup only. - } -} - -function cleanupTempFileSync(tmpPath: string, ops: AtomicWriteSyncOps): void { - try { - ops.unlink(tmpPath); - } catch { - // Best-effort cleanup only. - } -} - -/** @internal Exported for retry/cleanup tests. */ -export async function atomicWriteAsyncWithOps( - filePath: string, - content: string, - encoding: RetryableEncoding = "utf-8", - ops: AtomicWriteAsyncOps, -): Promise { - await ops.mkdir(dirname(filePath), { recursive: true }); - const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath); - await ops.writeFile(tmpPath, content, encoding); - - let lastError: unknown = null; - let attempts = 0; - - for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) { - try { - await ops.rename(tmpPath, filePath); - return; - } catch (error) { - lastError = error; - if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) { - break; - } - await ops.sleep(computeRetryDelayMs(attempts)); - } - } - - await cleanupTempFileAsync(tmpPath, ops); - throw buildAtomicWriteError(filePath, attempts, lastError); -} - -/** @internal Exported for retry/cleanup tests. */ -export function atomicWriteSyncWithOps( - filePath: string, - content: string, - encoding: RetryableEncoding = "utf-8", - ops: AtomicWriteSyncOps, -): void { - ops.mkdir(dirname(filePath), { recursive: true }); - const tmpPath = ops.createTempPath?.(filePath) ?? defaultTempPath(filePath); - ops.writeFile(tmpPath, content, encoding); - - let lastError: unknown = null; - let attempts = 0; - - for (attempts = 1; attempts <= MAX_RENAME_ATTEMPTS; attempts++) { - try { - ops.rename(tmpPath, filePath); - return; - } catch (error) { - lastError = error; - if (!isTransientLockError(error) || attempts === MAX_RENAME_ATTEMPTS) { - break; - } - ops.sleep(computeRetryDelayMs(attempts)); - } - } - - cleanupTempFileSync(tmpPath, ops); - throw buildAtomicWriteError(filePath, attempts, lastError); -} - -const DEFAULT_ASYNC_OPS: AtomicWriteAsyncOps = { - mkdir: async (path, options) => { - await fs.mkdir(path, options); - }, - writeFile: (path, content, encoding) => fs.writeFile(path, content, encoding), - rename: (from, to) => fs.rename(from, to), - unlink: (path) => fs.unlink(path), - sleep: delay, -}; - -const DEFAULT_SYNC_OPS: AtomicWriteSyncOps = { - mkdir: (path, options) => mkdirSync(path, options), - writeFile: (path, content, encoding) => writeFileSync(path, content, encoding), - rename: (from, to) => renameSync(from, to), - unlink: (path) => unlinkSync(path), - sleep: sleepSync, -}; - -/** - * Atomically writes content to a file by writing to a temp file first, - * then renaming. Prevents partial/corrupt files on crash. - */ -export function atomicWriteSync(filePath: string, content: string, encoding: BufferEncoding = "utf-8"): void { - return atomicWriteSyncWithOps(filePath, content, encoding, DEFAULT_SYNC_OPS); -} - -/** - * Async variant of atomicWriteSync. Atomically writes content to a file - * by writing to a temp file first, then renaming. - */ -export async function atomicWriteAsync(filePath: string, content: string, encoding: BufferEncoding = "utf-8"): Promise { - return atomicWriteAsyncWithOps(filePath, content, encoding, DEFAULT_ASYNC_OPS); -} diff --git a/src/resources/extensions/gsd/auto-artifact-paths.ts b/src/resources/extensions/gsd/auto-artifact-paths.ts deleted file mode 100644 index bfc61940f..000000000 --- a/src/resources/extensions/gsd/auto-artifact-paths.ts +++ /dev/null @@ -1,135 +0,0 @@ -// SF Auto-mode — Artifact Path Resolution -// -// resolveExpectedArtifactPath and diagnoseExpectedArtifact moved here from -// auto-recovery.ts (Phase 5 dead-code cleanup). The artifact verification -// function was removed entirely — callers now query WorkflowEngine directly. - -import { - resolveMilestonePath, - resolveSlicePath, - relMilestoneFile, - relSliceFile, - buildMilestoneFileName, - buildSliceFileName, - buildTaskFileName, -} from "./paths.js"; -import { parseUnitId } from "./unit-id.js"; -import { join } from "node:path"; - -/** - * Resolve the expected artifact for a unit to an absolute path. - */ -export function resolveExpectedArtifactPath( - unitType: string, - unitId: string, - base: string, -): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - switch (unitType) { - case "discuss-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "CONTEXT")) : null; - } - case "discuss-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "CONTEXT")) : null; - } - case "research-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null; - } - case "plan-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null; - } - case "research-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null; - } - case "plan-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null; - } - case "reassess-roadmap": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null; - } - case "run-uat": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null; - } - case "execute-task": { - const dir = resolveSlicePath(base, mid, sid!); - return dir && tid - ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) - : null; - } - case "complete-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null; - } - case "validate-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "VALIDATION")) : null; - } - case "complete-milestone": { - const dir = resolveMilestonePath(base, mid); - return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; - } - case "replan-slice": { - const dir = resolveSlicePath(base, mid, sid!); - return dir ? join(dir, buildSliceFileName(sid!, "REPLAN")) : null; - } - case "rewrite-docs": - return null; - case "gate-evaluate": - // Gate evaluate writes to DB quality_gates table — verified via state derivation - return null; - case "reactive-execute": - // Reactive execute produces multiple task summaries — verified separately - return null; - default: - return null; - } -} - -export function diagnoseExpectedArtifact( - unitType: string, - unitId: string, - base: string, -): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - switch (unitType) { - case "discuss-milestone": - return `${relMilestoneFile(base, mid, "CONTEXT")} (milestone context from discussion)`; - case "discuss-slice": - return `${relSliceFile(base, mid, sid!, "CONTEXT")} (slice context from discussion)`; - case "research-milestone": - return `${relMilestoneFile(base, mid, "RESEARCH")} (milestone research)`; - case "plan-milestone": - return `${relMilestoneFile(base, mid, "ROADMAP")} (milestone roadmap)`; - case "research-slice": - return `${relSliceFile(base, mid, sid!, "RESEARCH")} (slice research)`; - case "plan-slice": - return `${relSliceFile(base, mid, sid!, "PLAN")} (slice plan)`; - case "execute-task": { - return `Task ${tid} marked [x] in ${relSliceFile(base, mid, sid!, "PLAN")} + summary written`; - } - case "complete-slice": - return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid, "ROADMAP")} + summary + UAT written`; - case "replan-slice": - return `${relSliceFile(base, mid, sid!, "REPLAN")} + updated ${relSliceFile(base, mid, sid!, "PLAN")}`; - case "rewrite-docs": - return "Active overrides resolved in .gsd/OVERRIDES.md + plan documents updated"; - case "reassess-roadmap": - return `${relSliceFile(base, mid, sid!, "ASSESSMENT")} (roadmap reassessment)`; - case "run-uat": - return `${relSliceFile(base, mid, sid!, "ASSESSMENT")} (UAT assessment result)`; - case "validate-milestone": - return `${relMilestoneFile(base, mid, "VALIDATION")} (milestone validation report)`; - case "complete-milestone": - return `${relMilestoneFile(base, mid, "SUMMARY")} (milestone summary)`; - default: - return null; - } -} diff --git a/src/resources/extensions/gsd/auto-budget.ts b/src/resources/extensions/gsd/auto-budget.ts deleted file mode 100644 index 290f336f0..000000000 --- a/src/resources/extensions/gsd/auto-budget.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Budget alert level tracking and enforcement for auto-mode. - * Pure functions — no module state or side effects. - */ - -import type { BudgetEnforcementMode } from "./types.js"; - -export type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100; - -export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel { - if (budgetPct >= 1.0) return 100; - if (budgetPct >= 0.90) return 90; - if (budgetPct >= 0.80) return 80; - if (budgetPct >= 0.75) return 75; - return 0; -} - -export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null { - const currentLevel = getBudgetAlertLevel(budgetPct); - if (currentLevel === 0 || currentLevel <= previousLevel) return null; - return currentLevel; -} - -export function getBudgetEnforcementAction( - enforcement: BudgetEnforcementMode, - budgetPct: number, -): "none" | "warn" | "pause" | "halt" { - if (budgetPct < 1.0) return "none"; - if (enforcement === "halt") return "halt"; - if (enforcement === "pause") return "pause"; - return "warn"; -} diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts deleted file mode 100644 index c60fc22d0..000000000 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ /dev/null @@ -1,975 +0,0 @@ -/** - * Auto-mode Dashboard — progress widget rendering, elapsed time formatting, - * unit description helpers, and slice progress caching. - * - * Pure functions that accept specific parameters — no module-level globals - * or AutoContext dependency. State accessors are passed as callbacks. - */ - -import type { - ExtensionContext, - ExtensionCommandContext, - SessionMessageEntry, - ReadonlyFooterDataProvider, - Theme, -} from "@sf-run/pi-coding-agent"; -import type { GSDState } from "./types.js"; -import { getCurrentBranch } from "./worktree.js"; -import { getActiveHook } from "./post-unit-hooks.js"; -import { getLedger, getProjectTotals } from "./metrics.js"; -import { getErrorMessage } from "./error-utils.js"; -import { - resolveMilestoneFile, - resolveSliceFile, -} from "./paths.js"; -import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; -import { readFileSync, writeFileSync, existsSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { truncateToWidth, visibleWidth } from "@sf-run/pi-tui"; -import { makeUI } from "../shared/tui.js"; -import { GLYPH, INDENT } from "../shared/mod.js"; -import { computeProgressScore } from "./progress-score.js"; -import { getActiveWorktreeName } from "./worktree-command.js"; -import { - getGlobalGSDPreferencesPath, - getProjectGSDPreferencesPath, - parsePreferencesMarkdown, -} from "./preferences.js"; -import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js"; -import { parseUnitId } from "./unit-id.js"; -import { - formatRtkSavingsLabel, - getRtkSessionSavings, - type RtkSessionSavings, -} from "../shared/rtk-session-stats.js"; -import { logWarning } from "./workflow-logger.js"; -import { formattedShortcutPair } from "./shortcut-defs.js"; - -// ─── UAT Slice Extraction ───────────────────────────────────────────────────── - -/** - * Extract the target slice ID from a run-uat unit ID (e.g. "M001/S01" → "S01"). - * Returns null if the format doesn't match. - */ -export function extractUatSliceId(unitId: string): string | null { - const { slice } = parseUnitId(unitId); - if (slice?.startsWith("S")) return slice; - return null; -} - -// ─── Dashboard Data ─────────────────────────────────────────────────────────── - -/** Dashboard data for the overlay */ -export interface AutoDashboardData { - active: boolean; - paused: boolean; - stepMode: boolean; - startTime: number; - elapsed: number; - currentUnit: { type: string; id: string; startedAt: number } | null; - basePath: string; - /** Running cost and token totals from metrics ledger */ - totalCost: number; - totalTokens: number; - /** Projected remaining cost based on unit-type averages (undefined if insufficient data) */ - projectedRemainingCost?: number; - /** Whether token profile has been auto-downgraded due to budget prediction */ - profileDowngraded?: boolean; - /** Number of pending captures awaiting triage (0 if none or file missing) */ - pendingCaptureCount: number; - /** RTK token savings for the current session, or null when unavailable. */ - rtkSavings?: RtkSessionSavings | null; - /** Whether RTK is enabled via experimental.rtk preference. False when not opted in. */ - rtkEnabled?: boolean; - /** Cross-process: another auto-mode session detected via auto.lock (PID, startedAt) */ - remoteSession?: { pid: number; startedAt: string; unitType: string; unitId: string }; -} - -// ─── Unit Description Helpers ───────────────────────────────────────────────── - -export function unitVerb(unitType: string): string { - if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; - switch (unitType) { - case "discuss-milestone": - case "discuss-slice": return "discussing"; - case "research-milestone": - case "research-slice": return "researching"; - case "plan-milestone": - case "plan-slice": return "planning"; - case "execute-task": return "executing"; - case "complete-slice": return "completing"; - case "replan-slice": return "replanning"; - case "rewrite-docs": return "rewriting"; - case "reassess-roadmap": return "reassessing"; - case "run-uat": return "running UAT"; - case "custom-step": return "executing workflow step"; - default: return unitType; - } -} - -export function unitPhaseLabel(unitType: string): string { - if (unitType.startsWith("hook/")) return "HOOK"; - switch (unitType) { - case "discuss-milestone": - case "discuss-slice": return "DISCUSS"; - case "research-milestone": return "RESEARCH"; - case "research-slice": return "RESEARCH"; - case "plan-milestone": return "PLAN"; - case "plan-slice": return "PLAN"; - case "execute-task": return "EXECUTE"; - case "complete-slice": return "COMPLETE"; - case "replan-slice": return "REPLAN"; - case "rewrite-docs": return "REWRITE"; - case "reassess-roadmap": return "REASSESS"; - case "run-uat": return "UAT"; - case "custom-step": return "WORKFLOW"; - default: return unitType.toUpperCase(); - } -} - -function peekNext(unitType: string, state: GSDState): string { - // Show active hook info in progress display - const activeHookState = getActiveHook(); - if (activeHookState) { - return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`; - } - - const sid = state.activeSlice?.id ?? ""; - if (unitType.startsWith("hook/")) return `continue ${sid}`; - switch (unitType) { - case "discuss-milestone": return "research or plan milestone"; - case "discuss-slice": return "plan slice"; - case "research-milestone": return "plan milestone roadmap"; - case "plan-milestone": return "plan or execute first slice"; - case "research-slice": return `plan ${sid}`; - case "plan-slice": return "execute first task"; - case "execute-task": return `continue ${sid}`; - case "complete-slice": return "reassess roadmap"; - case "replan-slice": return `re-execute ${sid}`; - case "rewrite-docs": return "continue execution"; - case "reassess-roadmap": return "advance to next slice"; - case "run-uat": return "reassess roadmap"; - default: return ""; - } -} - -/** - * Describe what the next unit will be, based on current state. - */ -export function describeNextUnit(state: GSDState): { label: string; description: string } { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title; - const tid = state.activeTask?.id; - const tTitle = state.activeTask?.title; - - switch (state.phase) { - case "needs-discussion": - return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." }; - case "pre-planning": - return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." }; - case "planning": - return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." }; - case "executing": - return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." }; - case "summarizing": - return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." }; - case "replanning-slice": - return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." }; - case "completing-milestone": - return { label: "Complete milestone", description: "Write milestone summary." }; - case "evaluating-gates": - return { label: `Evaluate gates for ${sid}: ${sTitle}`, description: "Parallel quality gate assessment before execution." }; - default: - return { label: "Continue", description: "Execute the next step." }; - } -} - -// ─── Elapsed Time Formatting ────────────────────────────────────────────────── - -/** Format elapsed time since auto-mode started */ -export function formatAutoElapsed(autoStartTime: number): string { - if (!autoStartTime || autoStartTime <= 0 || !Number.isFinite(autoStartTime)) return ""; - const ms = Date.now() - autoStartTime; - if (ms < 0 || ms > 30 * 24 * 3600_000) return ""; // negative or >30 days = invalid - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h ${rm}m`; -} - -/** Format token counts for compact display */ -export function formatWidgetTokens(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; -} - -// ─── ETA Estimation ────────────────────────────────────────────────────────── - -/** - * Estimate remaining time based on average unit duration from the metrics ledger. - * Returns a formatted string like "~12m remaining" or null if insufficient data. - */ -export function estimateTimeRemaining(): string | null { - const ledger = getLedger(); - if (!ledger || ledger.units.length < 2) return null; - - const sliceProgress = getRoadmapSlicesSync(); - if (!sliceProgress || sliceProgress.total === 0) return null; - - const remainingSlices = sliceProgress.total - sliceProgress.done; - if (remainingSlices <= 0) return null; - - // Compute average duration per completed slice from the ledger - const completedSliceUnits = ledger.units.filter( - u => u.finishedAt > 0 && u.startedAt > 0, - ); - if (completedSliceUnits.length < 2) return null; - - const totalDuration = completedSliceUnits.reduce( - (sum, u) => sum + (u.finishedAt - u.startedAt), 0, - ); - const avgDuration = totalDuration / completedSliceUnits.length; - - // Rough estimate: remaining slices × average units per slice × avg duration - const completedSlices = sliceProgress.done || 1; - const unitsPerSlice = completedSliceUnits.length / completedSlices; - const estimatedMs = remainingSlices * unitsPerSlice * avgDuration; - - if (estimatedMs < 5_000) return null; // Too small to display - - const s = Math.floor(estimatedMs / 1000); - if (s < 60) return `~${s}s remaining`; - const m = Math.floor(s / 60); - if (m < 60) return `~${m}m remaining`; - const h = Math.floor(m / 60); - const rm = m % 60; - return rm > 0 ? `~${h}h ${rm}m remaining` : `~${h}h remaining`; -} - -// ─── Slice Progress Cache ───────────────────────────────────────────────────── - -/** Cached task detail for the widget task checklist */ -interface CachedTaskDetail { - id: string; - title: string; - done: boolean; -} - -/** Cached slice progress for the widget — avoid async in render */ -let cachedSliceProgress: { - done: number; - total: number; - milestoneId: string; - /** Real task progress for the active slice, if its plan file exists */ - activeSliceTasks: { done: number; total: number } | null; - /** Full task list for the active slice checklist */ - taskDetails: CachedTaskDetail[] | null; -} | null = null; - -export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void { - try { - // Normalize slices: prefer DB, fall back to parser - type NormSlice = { id: string; done: boolean; title: string }; - let normSlices: NormSlice[]; - if (isDbAvailable()) { - normSlices = getMilestoneSlices(mid).map(s => ({ id: s.id, done: s.status === "complete", title: s.title })); - } else { - normSlices = []; - } - - let activeSliceTasks: { done: number; total: number } | null = null; - let taskDetails: CachedTaskDetail[] | null = null; - if (activeSid) { - try { - if (isDbAvailable()) { - const dbTasks = getSliceTasks(mid, activeSid); - if (dbTasks.length > 0) { - activeSliceTasks = { - done: dbTasks.filter(t => t.status === "complete" || t.status === "done").length, - total: dbTasks.length, - }; - taskDetails = dbTasks.map(t => ({ id: t.id, title: t.title, done: t.status === "complete" || t.status === "done" })); - } - } - } catch (err) { - // Non-fatal — just omit task count - logWarning("dashboard", `operation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - cachedSliceProgress = { - done: normSlices.filter(s => s.done).length, - total: normSlices.length, - milestoneId: mid, - activeSliceTasks, - taskDetails, - }; - } catch (err) { - // Non-fatal — widget just won't show progress bar - logWarning("dashboard", `operation failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null; taskDetails: CachedTaskDetail[] | null } | null { - return cachedSliceProgress; -} - -export function clearSliceProgressCache(): void { - cachedSliceProgress = null; -} - -// ─── Last Commit Cache ──────────────────────────────────────────────────────── - -/** Cached last commit info — refreshed on the 15s timer, not every render */ -let cachedLastCommit: { timeAgo: string; message: string } | null = null; -let lastCommitFetchedAt = 0; - -function refreshLastCommit(basePath: string): void { - try { - const raw = execFileSync("git", ["log", "-1", "--format=%cr|%s"], { - cwd: basePath, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - timeout: 3000, - }).trim(); - const sep = raw.indexOf("|"); - if (sep > 0) { - cachedLastCommit = { - timeAgo: raw.slice(0, sep).replace(/ ago$/, ""), - message: raw.slice(sep + 1), - }; - } - lastCommitFetchedAt = Date.now(); - } catch (err) { - // Non-fatal — just skip last commit display - logWarning("dashboard", `operation failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -function getLastCommit(basePath: string): { timeAgo: string; message: string } | null { - // Refresh at most every 15 seconds - if (Date.now() - lastCommitFetchedAt > 15_000) { - refreshLastCommit(basePath); - } - return cachedLastCommit; -} - -// ─── Footer Factory ─────────────────────────────────────────────────────────── - -/** - * Footer factory used by auto-mode. - * Keep footer minimal but preserve extension status context from setStatus(). - */ -function sanitizeFooterStatus(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -export const hideFooter = (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => ({ - render(width: number): string[] { - const extensionStatuses = footerData.getExtensionStatuses(); - if (extensionStatuses.size === 0) return []; - const statusLine = Array.from(extensionStatuses.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => sanitizeFooterStatus(text)) - .join(" "); - return [truncateToWidth(theme.fg("dim", statusLine), width, theme.fg("dim", "..."))]; - }, - invalidate() {}, - dispose() {}, -}); - -// ─── Widget Display Mode ────────────────────────────────────────────────────── - -/** Widget display modes: full → small → min → off → full */ -export type WidgetMode = "full" | "small" | "min" | "off"; -const WIDGET_MODES: WidgetMode[] = ["full", "small", "min", "off"]; -let widgetMode: WidgetMode = "full"; -let widgetModeInitialized = false; -let widgetModePreferencePath: string | null = null; - -function safeReadTextFile(path: string): string | null { - try { - if (!existsSync(path)) return null; - return readFileSync(path, "utf-8"); - } catch { - return null; - } -} - -function readWidgetModeFromFile(path: string): WidgetMode | undefined { - const raw = safeReadTextFile(path); - if (!raw) return undefined; - const prefs = parsePreferencesMarkdown(raw); - const saved = prefs?.widget_mode; - if (saved && WIDGET_MODES.includes(saved as WidgetMode)) { - return saved as WidgetMode; - } - return undefined; -} - -function resolveWidgetModePreferencePath( - projectPath = getProjectGSDPreferencesPath(), - globalPath = getGlobalGSDPreferencesPath(), -): string { - if (readWidgetModeFromFile(projectPath)) { - return projectPath; - } - - if (readWidgetModeFromFile(globalPath)) { - return globalPath; - } - - if (safeReadTextFile(projectPath) !== null) return projectPath; - if (safeReadTextFile(globalPath) !== null) return globalPath; - return getGlobalGSDPreferencesPath(); -} - -/** Load widget mode from preferences (once). */ -function ensureWidgetModeLoaded(projectPath?: string, globalPath?: string): void { - if (widgetModeInitialized) return; - widgetModeInitialized = true; - try { - const resolvedProjectPath = projectPath ?? getProjectGSDPreferencesPath(); - const resolvedGlobalPath = globalPath ?? getGlobalGSDPreferencesPath(); - const saved = readWidgetModeFromFile(resolvedProjectPath) ?? readWidgetModeFromFile(resolvedGlobalPath); - if (saved && WIDGET_MODES.includes(saved as WidgetMode)) { - widgetMode = saved as WidgetMode; - } - widgetModePreferencePath = resolveWidgetModePreferencePath(resolvedProjectPath, resolvedGlobalPath); - } catch (err) { /* non-fatal — use default */ - logWarning("dashboard", `operation failed: ${getErrorMessage(err)}`); - widgetModePreferencePath = getGlobalGSDPreferencesPath(); - } -} - -/** - * Persist widget mode to the preference file that owns the effective value. - * Project-scoped widget_mode wins over global; if neither scope defines it, - * we prefer an existing project preferences file and otherwise fall back to - * the global preferences file. - */ -function persistWidgetMode( - mode: WidgetMode, - prefsPath = widgetModePreferencePath ?? resolveWidgetModePreferencePath(), -): void { - try { - let content = ""; - if (existsSync(prefsPath)) { - content = readFileSync(prefsPath, "utf-8"); - } - const line = `widget_mode: ${mode}`; - const re = /^widget_mode:\s*\S+/m; - if (re.test(content)) { - content = content.replace(re, line); - } else { - content = content.trimEnd() + "\n" + line + "\n"; - } - writeFileSync(prefsPath, content, "utf-8"); - } catch (err) { /* non-fatal — mode still set in memory */ - logWarning("dashboard", `file write failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** Cycle to the next widget mode. Returns the new mode. */ -export function cycleWidgetMode(projectPath?: string, globalPath?: string): WidgetMode { - ensureWidgetModeLoaded(projectPath, globalPath); - const idx = WIDGET_MODES.indexOf(widgetMode); - widgetMode = WIDGET_MODES[(idx + 1) % WIDGET_MODES.length]; - persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath)); - return widgetMode; -} - -/** Set widget mode directly. */ -export function setWidgetMode(mode: WidgetMode, projectPath?: string, globalPath?: string): void { - ensureWidgetModeLoaded(projectPath, globalPath); - widgetMode = mode; - persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath)); -} - -/** Get current widget mode. */ -export function getWidgetMode(projectPath?: string, globalPath?: string): WidgetMode { - ensureWidgetModeLoaded(projectPath, globalPath); - return widgetMode; -} - -/** Test-only reset for widget mode caching. */ -export function _resetWidgetModeForTests(): void { - widgetMode = "full"; - widgetModeInitialized = false; - widgetModePreferencePath = null; -} - -// ─── Progress Widget ────────────────────────────────────────────────────────── - -/** State accessors passed to updateProgressWidget to avoid direct global access */ -export interface WidgetStateAccessors { - getAutoStartTime(): number; - isStepMode(): boolean; - getCmdCtx(): ExtensionCommandContext | null; - getBasePath(): string; - isVerbose(): boolean; - /** True while newSession() is in-flight — render must not access session state. */ - isSessionSwitching(): boolean; - /** Fully-qualified dispatched model ID (provider/id) set after model selection + hook overrides (#2899). */ - getCurrentDispatchedModelId(): string | null; -} - -export function updateProgressWidget( - ctx: ExtensionContext, - unitType: string, - unitId: string, - state: GSDState, - accessors: WidgetStateAccessors, - tierBadge?: string, -): void { - if (!ctx.hasUI) return; - - const verb = unitVerb(unitType); - const phaseLabel = unitPhaseLabel(unitType); - const mid = state.activeMilestone; - const isHook = unitType.startsWith("hook/"); - - // When run-uat is executing for a just-completed slice (e.g. S01), - // deriveState() has already advanced activeSlice to the next one (S02). - // Override the displayed slice to match the UAT target from the unit ID. - const uatTargetSliceId = unitType === "run-uat" ? extractUatSliceId(unitId) : null; - const slice = uatTargetSliceId - ? { id: uatTargetSliceId, title: state.activeSlice?.title ?? "" } - : state.activeSlice; - const task = state.activeTask; - - // Cache git branch at widget creation time (not per render) - let cachedBranch: string | null = null; - try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch (err) { /* not in git repo */ - logWarning("dashboard", `git branch detection failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // Cache short pwd (last 2 path segments only) + worktree/branch info - let widgetPwd: string; - { - let fullPwd = process.cwd(); - const widgetHome = process.env.HOME || process.env.USERPROFILE; - if (widgetHome && fullPwd.startsWith(widgetHome)) { - fullPwd = `~${fullPwd.slice(widgetHome.length)}`; - } - const parts = fullPwd.split("/"); - widgetPwd = parts.length > 2 ? parts.slice(-2).join("/") : fullPwd; - } - const worktreeName = getActiveWorktreeName(); - if (worktreeName && cachedBranch) { - widgetPwd = `${widgetPwd} (\u2387 ${cachedBranch})`; - } else if (cachedBranch) { - widgetPwd = `${widgetPwd} (${cachedBranch})`; - } - - // Pre-fetch last commit for display - refreshLastCommit(accessors.getBasePath()); - - // Cache the effective service tier at widget creation time (reads preferences) - const effectiveServiceTier = getEffectiveServiceTier(); - - ctx.ui.setWidget("gsd-progress", (tui, theme) => { - let pulseBright = true; - let cachedLines: string[] | undefined; - let cachedWidth: number | undefined; - let cachedRtkLabel: string | null | undefined; - - const refreshRtkLabel = (): void => { - try { - const sessionId = ctx.sessionManager.getSessionId(); - const savings = sessionId ? getRtkSessionSavings(accessors.getBasePath(), sessionId) : null; - cachedRtkLabel = formatRtkSavingsLabel(savings); - } catch (err) { - logWarning("dashboard", `RTK savings lookup failed: ${err instanceof Error ? (err as Error).message : String(err)}`); - cachedRtkLabel = null; - } - }; - - refreshRtkLabel(); - - const pulseTimer = setInterval(() => { - pulseBright = !pulseBright; - cachedLines = undefined; - tui.requestRender(); - }, 800); - - // Refresh progress cache from disk every 15s so the widget reflects - // task/slice completion mid-unit. Without this, the progress bar only - // updates at dispatch time, appearing frozen during long-running units. - // 15s (vs 5s) reduces synchronous file I/O on the hot path. - const progressRefreshTimer = setInterval(() => { - try { - if (mid) { - updateSliceProgressCache(accessors.getBasePath(), mid.id, slice?.id); - } - refreshRtkLabel(); - cachedLines = undefined; - } catch (err) { /* non-fatal */ - logWarning("dashboard", `DB status update failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, 15_000); - - return { - render(width: number): string[] { - if (cachedLines && cachedWidth === width) return cachedLines; - - // While newSession() is in-flight, session state is mid-mutation. - // Accessing cmdCtx.sessionManager or cmdCtx.getContextUsage() can - // block the render loop and freeze the TUI. Return the last cached - // frame (or an empty frame on first render) until the switch settles. - if (accessors.isSessionSwitching()) { - return cachedLines ?? []; - } - - const ui = makeUI(theme, width); - const lines: string[] = []; - const pad = INDENT.base; - - // ── Line 1: Top bar ─────────────────────────────────────────────── - lines.push(...ui.bar()); - - const dot = pulseBright - ? theme.fg("accent", GLYPH.statusActive) - : theme.fg("dim", GLYPH.statusPending); - const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); - const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO"; - - // Health indicator in header - const score = computeProgressScore(); - const healthColor = score.level === "green" ? "success" - : score.level === "yellow" ? "warning" - : "error"; - const healthIcon = score.level === "green" ? GLYPH.statusActive - : score.level === "yellow" ? "!" - : "x"; - const healthStr = ` ${theme.fg(healthColor, healthIcon)} ${theme.fg(healthColor, score.summary)}`; - - const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("SF"))} ${theme.fg("success", modeTag)}${healthStr}`; - - // ETA in header right, after elapsed - const eta = estimateTimeRemaining(); - const etaShort = eta ? eta.replace(" remaining", " left") : null; - const headerRight = elapsed - ? (etaShort - ? `${theme.fg("dim", elapsed)} ${theme.fg("dim", "·")} ${theme.fg("dim", etaShort)}` - : theme.fg("dim", elapsed)) - : ""; - lines.push(rightAlign(headerLeft, headerRight, width)); - - // Show health signal details when degraded (yellow/red) - if (score.level !== "green" && score.signals.length > 0 && widgetMode !== "min") { - // Show up to 3 most relevant signals in compact form - const topSignals = score.signals - .filter(s => s.kind === "negative") - .slice(0, 3); - if (topSignals.length > 0) { - const signalStr = topSignals - .map(s => theme.fg("dim", s.label)) - .join(theme.fg("dim", " · ")); - lines.push(`${pad} ${signalStr}`); - } - } - - // ── Gather stats (needed by multiple modes) ───────────────────── - const cmdCtx = accessors.getCmdCtx(); - let totalInput = 0; - let totalCacheRead = 0; - if (cmdCtx) { - for (const entry of cmdCtx.sessionManager.getEntries()) { - if (entry.type === "message") { - const msgEntry = entry as SessionMessageEntry; - if (msgEntry.message?.role === "assistant") { - const u = (msgEntry.message as any).usage; - if (u) { - totalInput += u.input || 0; - totalCacheRead += u.cacheRead || 0; - } - } - } - } - } - const mLedger = getLedger(); - const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null; - const cumulativeCost = autoTotals?.cost ?? 0; - const cxUsage = cmdCtx?.getContextUsage?.(); - const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0; - const cxPctVal = cxUsage?.percent ?? 0; - const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?"; - - // Model display — prefer dispatched model ID (set after selectAndApplyModel - // + hook overrides) over cmdCtx?.model which can be stale (#2899). - const dispatchedModelId = accessors.getCurrentDispatchedModelId(); - const modelId = dispatchedModelId - ? dispatchedModelId.split("/").slice(1).join("/") || dispatchedModelId - : (cmdCtx?.model?.id ?? ""); - const modelProvider = dispatchedModelId - ? dispatchedModelId.split("/")[0] || "" - : (cmdCtx?.model?.provider ?? ""); - const tierIcon = resolveServiceTierIcon(effectiveServiceTier, modelId); - const modelDisplay = (modelProvider && modelId - ? `${modelProvider}/${modelId}` - : modelId) + (tierIcon ? ` ${tierIcon}` : ""); - - // ── Mode: off — return empty ────────────────────────────────── - if (widgetMode === "off") { - cachedLines = []; - cachedWidth = width; - return []; - } - - // ── Mode: min — header line only ────────────────────────────── - if (widgetMode === "min") { - lines.push(...ui.bar()); - cachedLines = lines; - cachedWidth = width; - return lines; - } - - // ── Mode: small — header + progress bar + compact stats ─────── - if (widgetMode === "small") { - lines.push(""); - - // Action line - const target = task ? `${task.id}: ${task.title}` : unitId; - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; - lines.push(rightAlign(actionLeft, theme.fg("dim", phaseLabel), width)); - - // Progress bar - const roadmapSlices = mid ? getRoadmapSlicesSync() : null; - if (roadmapSlices) { - const { done, total, activeSliceTasks } = roadmapSlices; - const barWidth = Math.max(6, Math.min(18, Math.floor(width * 0.25))); - const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); - const bar = theme.fg("success", "━".repeat(filled)) - + theme.fg("dim", "─".repeat(barWidth - filled)); - let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; - if (activeSliceTasks && activeSliceTasks.total > 0) { - const tn = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); - meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${tn}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`; - } - lines.push(`${pad}${bar} ${meta}`); - } - - // Compact stats: cost + context only - const smallStats: string[] = []; - if (cumulativeCost) smallStats.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`)); - const cxDisplay = `${cxPct}%ctx`; - if (cxPctVal > 90) smallStats.push(theme.fg("error", cxDisplay)); - else if (cxPctVal > 70) smallStats.push(theme.fg("warning", cxDisplay)); - else smallStats.push(theme.fg("dim", cxDisplay)); - if (smallStats.length > 0) { - lines.push(rightAlign("", smallStats.join(theme.fg("dim", " ")), width)); - } - - lines.push(...ui.bar()); - cachedLines = lines; - cachedWidth = width; - return lines; - } - - // ── Mode: full — complete two-column layout ─────────────────── - lines.push(""); - - // Context section: milestone + slice + model - const hasContext = !!(mid || (slice && unitType !== "research-milestone" && unitType !== "plan-milestone")); - if (mid) { - const modelTag = modelDisplay ? theme.fg("muted", ` ${modelDisplay}`) : ""; - lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}${modelTag}`, width, "…")); - } - if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") { - lines.push(truncateToWidth( - `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`, - width, "…", - )); - } - if (hasContext) lines.push(""); - - const target = task ? `${task.id}: ${task.title}` : unitId; - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; - const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : ""; - const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`; - lines.push(rightAlign(actionLeft, phaseBadge, width)); - - lines.push(""); - - // Two-column body - const minTwoColWidth = 76; - const roadmapSlices = mid ? getRoadmapSlicesSync() : null; - const taskDetailsCol = roadmapSlices?.taskDetails ?? null; - const useTwoCol = width >= minTwoColWidth && taskDetailsCol !== null && taskDetailsCol.length > 0; - const leftColWidth = useTwoCol - ? Math.floor(width * (width >= 100 ? 0.45 : 0.50)) - : width; - - const leftLines: string[] = []; - - if (roadmapSlices) { - const { done, total, activeSliceTasks } = roadmapSlices; - const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4))); - const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); - const bar = theme.fg("success", "━".repeat(filled)) - + theme.fg("dim", "─".repeat(barWidth - filled)); - - let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`; - if (activeSliceTasks && activeSliceTasks.total > 0) { - const taskNum = isHook - ? Math.max(activeSliceTasks.done, 1) - : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); - meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`; - } - leftLines.push(`${pad}${bar} ${meta}`); - } - - // Build right column: task checklist - const rightLines: string[] = []; - const maxVisibleTasks = 8; - - // Max visible chars for task title text (before ANSI theming) - const maxTaskTitleLen = 45; - function truncTitle(s: string): string { - return s.length > maxTaskTitleLen ? s.slice(0, maxTaskTitleLen - 1) + "…" : s; - } - - function formatTaskLine(t: { id: string; title: string; done: boolean }, isCurrent: boolean): string { - const glyph = t.done - ? theme.fg("success", "*") - : isCurrent - ? theme.fg("accent", ">") - : theme.fg("dim", "."); - const id = isCurrent - ? theme.fg("accent", t.id) - : t.done - ? theme.fg("muted", t.id) - : theme.fg("dim", t.id); - const short = truncTitle(t.title); - const title = isCurrent - ? theme.fg("text", short) - : t.done - ? theme.fg("muted", short) - : theme.fg("text", short); - return `${glyph} ${id}: ${title}`; - } - - if (useTwoCol && taskDetailsCol) { - for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) { - rightLines.push(formatTaskLine(t, !!(task && t.id === task.id))); - } - if (taskDetailsCol.length > maxVisibleTasks) { - rightLines.push(theme.fg("dim", ` +${taskDetailsCol.length - maxVisibleTasks} more`)); - } - } else if (!useTwoCol && taskDetailsCol && taskDetailsCol.length > 0) { - for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) { - leftLines.push(`${pad}${formatTaskLine(t, !!(task && t.id === task.id))}`); - } - } - - // Compose columns - if (useTwoCol) { - const maxRows = Math.max(leftLines.length, rightLines.length); - if (maxRows > 0) { - lines.push(""); - for (let i = 0; i < maxRows; i++) { - const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth, "…"), leftColWidth); - const right = rightLines[i] ?? ""; - lines.push(`${left}${right}`); - } - } - } else { - if (leftLines.length > 0) { - lines.push(""); - for (const l of leftLines) lines.push(truncateToWidth(l, width, "…")); - } - } - - // ── Footer: simplified stats + pwd + last commit + hints ──────── - lines.push(""); - { - const sp: string[] = []; - if (totalCacheRead + totalInput > 0) { - const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100); - const hitColor = hitRate >= 70 ? "success" : hitRate >= 40 ? "warning" : "error"; - sp.push(theme.fg(hitColor, `${hitRate}%hit`)); - } - if (cumulativeCost) sp.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`)); - - const cxDisplay = `${cxPct}%/${formatWidgetTokens(cxWindow)}`; - if (cxPctVal > 90) sp.push(theme.fg("error", cxDisplay)); - else if (cxPctVal > 70) sp.push(theme.fg("warning", cxDisplay)); - else sp.push(cxDisplay); - - const statsLine = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p)) - .join(theme.fg("dim", " ")); - if (statsLine) { - lines.push(rightAlign("", statsLine, width)); - } - if (cachedRtkLabel) { - lines.push(rightAlign("", theme.fg("dim", cachedRtkLabel), width)); - } - } - // Last commit info - const lastCommit = getLastCommit(accessors.getBasePath()); - const maxCommitLen = 65; - const commitMsg = lastCommit - ? lastCommit.message.length > maxCommitLen - ? lastCommit.message.slice(0, maxCommitLen - 1) + "…" - : lastCommit.message - : ""; - // Hints line - const hintParts: string[] = []; - hintParts.push("esc pause"); - hintParts.push(`${formattedShortcutPair("dashboard")} dashboard`); - hintParts.push(`${formattedShortcutPair("parallel")} parallel`); - const hintStr = theme.fg("dim", hintParts.join(" | ")); - const commitStr = lastCommit - ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${commitMsg}`) - : ""; - const locationStr = theme.fg("dim", widgetPwd); - if (commitStr) { - lines.push(rightAlign(`${pad}${locationStr} · ${commitStr}`, hintStr, width)); - } else { - lines.push(rightAlign(`${pad}${locationStr}`, hintStr, width)); - } - - lines.push(...ui.bar()); - - cachedLines = lines; - cachedWidth = width; - return lines; - }, - invalidate() { - cachedLines = undefined; - cachedWidth = undefined; - }, - dispose() { - clearInterval(pulseTimer); - if (progressRefreshTimer) clearInterval(progressRefreshTimer); - }, - }; - }); -} - -// ─── Right-align Helper ─────────────────────────────────────────────────────── - -/** Right-align helper: build a line with left content and right content. */ -function rightAlign(left: string, right: string, width: number): string { - const leftVis = visibleWidth(left); - const rightVis = visibleWidth(right); - const gap = Math.max(1, width - leftVis - rightVis); - return truncateToWidth(left + " ".repeat(gap) + right, width, "…"); -} - -/** Pad a string with trailing spaces to fill exactly `colWidth` (ANSI-aware). */ -function padToWidth(s: string, colWidth: number): string { - const vis = visibleWidth(s); - if (vis >= colWidth) return truncateToWidth(s, colWidth, "…"); - return s + " ".repeat(colWidth - vis); -} diff --git a/src/resources/extensions/gsd/auto-direct-dispatch.ts b/src/resources/extensions/gsd/auto-direct-dispatch.ts deleted file mode 100644 index 9a6f4c2a9..000000000 --- a/src/resources/extensions/gsd/auto-direct-dispatch.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Direct phase dispatch — handles manual /gsd dispatch commands. - * Resolves phase name → unit type + prompt, creates a session, and sends the message. - */ - -import type { - ExtensionAPI, - ExtensionCommandContext, -} from "@sf-run/pi-coding-agent"; - -import { deriveState } from "./state.js"; -import { loadFile } from "./files.js"; -import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; -import { parseRoadmap } from "./parsers-legacy.js"; -import { - resolveMilestoneFile, resolveSliceFile, relSliceFile, -} from "./paths.js"; -import { - buildResearchSlicePrompt, - buildResearchMilestonePrompt, - buildPlanSlicePrompt, - buildPlanMilestonePrompt, - buildExecuteTaskPrompt, - buildCompleteSlicePrompt, - buildCompleteMilestonePrompt, - buildReassessRoadmapPrompt, - buildRunUatPrompt, - buildReplanSlicePrompt, -} from "./auto-prompts.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { pauseAuto } from "./auto.js"; -import { - getWorkflowTransportSupportError, - getRequiredWorkflowToolsForAutoUnit, -} from "./workflow-mcp.js"; - -export async function dispatchDirectPhase( - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - phase: string, - base: string, -): Promise { - const state = await deriveState(base); - const mid = state.activeMilestone?.id; - const midTitle = state.activeMilestone?.title ?? ""; - - if (!mid) { - ctx.ui.notify("Cannot dispatch: no active milestone.", "warning"); - return; - } - - const normalized = phase.toLowerCase(); - let unitType: string; - let unitId: string; - let prompt: string; - - switch (normalized) { - case "research": - case "research-milestone": - case "research-slice": { - const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning"); - if (isSlice) { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title ?? ""; - if (!sid) { - ctx.ui.notify("Cannot dispatch research-slice: no active slice.", "warning"); - return; - } - - // When require_slice_discussion is enabled, pause auto-mode before - // each new slice so the user can discuss requirements first (#789). - const sliceContextFile = resolveSliceFile(base, mid, sid, "CONTEXT"); - const requireDiscussion = loadEffectiveGSDPreferences()?.preferences?.phases?.require_slice_discussion; - if (requireDiscussion && !sliceContextFile) { - ctx.ui.notify( - `Slice ${sid} requires discussion before planning. Run /gsd discuss to discuss this slice, then /gsd auto to resume.`, - "info", - ); - await pauseAuto(ctx, pi); - return; - } - - unitType = "research-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base); - } else { - unitType = "research-milestone"; - unitId = mid; - prompt = await buildResearchMilestonePrompt(mid, midTitle, base); - } - break; - } - - case "plan": - case "plan-milestone": - case "plan-slice": { - const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning"); - if (isSlice) { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title ?? ""; - if (!sid) { - ctx.ui.notify("Cannot dispatch plan-slice: no active slice.", "warning"); - return; - } - unitType = "plan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base); - } else { - unitType = "plan-milestone"; - unitId = mid; - prompt = await buildPlanMilestonePrompt(mid, midTitle, base); - } - break; - } - - case "execute": - case "execute-task": { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title ?? ""; - const tid = state.activeTask?.id; - const tTitle = state.activeTask?.title ?? ""; - if (!sid) { - ctx.ui.notify("Cannot dispatch execute-task: no active slice.", "warning"); - return; - } - if (!tid) { - ctx.ui.notify("Cannot dispatch execute-task: no active task.", "warning"); - return; - } - unitType = "execute-task"; - unitId = `${mid}/${sid}/${tid}`; - prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base); - break; - } - - case "complete": - case "complete-slice": - case "complete-milestone": { - const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing"); - if (isSlice) { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title ?? ""; - if (!sid) { - ctx.ui.notify("Cannot dispatch complete-slice: no active slice.", "warning"); - return; - } - unitType = "complete-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base); - } else { - unitType = "complete-milestone"; - unitId = mid; - prompt = await buildCompleteMilestonePrompt(mid, midTitle, base); - } - break; - } - - case "reassess": - case "reassess-roadmap": { - // DB primary path — get completed slices, fall back to file parsing when DB has no data - let completedSliceIds: string[] = []; - if (isDbAvailable()) { - completedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id); - } - if (completedSliceIds.length === 0) { - // File-based fallback: parse roadmap checkboxes - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapPath) { - const roadmapContent = await loadFile(roadmapPath); - if (roadmapContent) { - completedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id); - } - } - } - if (completedSliceIds.length === 0) { - ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning"); - return; - } - const completedSliceId = completedSliceIds[completedSliceIds.length - 1]; - unitType = "reassess-roadmap"; - unitId = `${mid}/${completedSliceId}`; - prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base); - break; - } - - case "uat": - case "run-uat": { - // UAT targets the most recently completed slice, not the active (next - // incomplete) slice. After slice completion, state.activeSlice advances - // to the next incomplete slice, so we find the last done slice from the - // roadmap instead (#1693). - let uatCompletedSliceIds: string[] = []; - if (isDbAvailable()) { - uatCompletedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id); - } - if (uatCompletedSliceIds.length === 0) { - // File-based fallback: parse roadmap checkboxes - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapPath) { - const roadmapContent = await loadFile(roadmapPath); - if (roadmapContent) { - uatCompletedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id); - } - } - } - if (uatCompletedSliceIds.length === 0) { - ctx.ui.notify("Cannot dispatch run-uat: no completed slices.", "warning"); - return; - } - const sid = uatCompletedSliceIds[uatCompletedSliceIds.length - 1]; - const uatFile = resolveSliceFile(base, mid, sid, "UAT"); - if (!uatFile) { - ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning"); - return; - } - const uatContent = await loadFile(uatFile); - if (!uatContent) { - ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning"); - return; - } - const uatPath = relSliceFile(base, mid, sid, "UAT"); - unitType = "run-uat"; - unitId = `${mid}/${sid}`; - prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base); - break; - } - - case "replan": - case "replan-slice": { - const sid = state.activeSlice?.id; - const sTitle = state.activeSlice?.title ?? ""; - if (!sid) { - ctx.ui.notify("Cannot dispatch replan-slice: no active slice.", "warning"); - return; - } - unitType = "replan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base); - break; - } - - default: - ctx.ui.notify( - `Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`, - "warning", - ); - return; - } - - const compatibilityError = getWorkflowTransportSupportError( - ctx.model?.provider, - getRequiredWorkflowToolsForAutoUnit(unitType), - { - projectRoot: base, - surface: "direct phase dispatch", - unitType, - authMode: ctx.model?.provider ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) : undefined, - baseUrl: ctx.model?.baseUrl, - }, - ); - if (compatibilityError) { - ctx.ui.notify(compatibilityError, "error"); - return; - } - - ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info"); - const result = await ctx.newSession(); - if (result.cancelled) { - ctx.ui.notify("Session creation cancelled.", "warning"); - return; - } - pi.sendMessage( - { customType: "gsd-dispatch", content: prompt, display: false }, - { triggerTurn: true }, - ); -} diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts deleted file mode 100644 index bcc86a8a0..000000000 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ /dev/null @@ -1,908 +0,0 @@ -/** - * Auto-mode Dispatch Table — declarative phase → unit mapping. - * - * Each rule maps a SF state to the unit type, unit ID, and prompt builder - * that should be dispatched. Rules are evaluated in order; the first match wins. - * - * This replaces the 130-line if-else chain in dispatchNextUnit with a - * data structure that is inspectable, testable per-rule, and extensible - * without modifying orchestration code. - */ - -import type { GSDState } from "./types.js"; -import type { GSDPreferences } from "./preferences.js"; -import type { UatType } from "./files.js"; -import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; -import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted, getMilestone } from "./gsd-db.js"; -import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js"; - -import { - gsdRoot, - resolveMilestoneFile, - resolveMilestonePath, - resolveSliceFile, - resolveSlicePath, - resolveTaskFile, - relSliceFile, - buildMilestoneFileName, - buildSliceFileName, -} from "./paths.js"; -import { parseRoadmap } from "./parsers-legacy.js"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { logWarning, logError } from "./workflow-logger.js"; -import { join } from "node:path"; -import { hasImplementationArtifacts } from "./auto-recovery.js"; -import { - buildDiscussMilestonePrompt, - buildResearchMilestonePrompt, - buildPlanMilestonePrompt, - buildResearchSlicePrompt, - buildPlanSlicePrompt, - buildExecuteTaskPrompt, - buildCompleteSlicePrompt, - buildCompleteMilestonePrompt, - buildValidateMilestonePrompt, - buildReplanSlicePrompt, - buildRunUatPrompt, - buildReassessRoadmapPrompt, - buildRewriteDocsPrompt, - buildReactiveExecutePrompt, - buildGateEvaluatePrompt, - buildParallelResearchSlicesPrompt, - checkNeedsReassessment, - checkNeedsRunUat, -} from "./auto-prompts.js"; -import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; -import { resolveUokFlags } from "./uok/flags.js"; -import { selectReactiveDispatchBatch } from "./uok/execution-graph.js"; - -// ─── Types ──────────────────────────────────────────────────────────────── - -export type DispatchAction = - | { - action: "dispatch"; - unitType: string; - unitId: string; - prompt: string; - pauseAfterDispatch?: boolean; - /** Name of the matched dispatch rule from the unified registry (journal provenance). */ - matchedRule?: string; - } - | { action: "stop"; reason: string; level: "info" | "warning" | "error"; matchedRule?: string } - | { action: "skip"; matchedRule?: string }; - -export interface DispatchContext { - basePath: string; - mid: string; - midTitle: string; - state: GSDState; - prefs: GSDPreferences | undefined; - session?: import("./auto/session.js").AutoSession; -} - -export interface DispatchRule { - /** Human-readable name for debugging and test identification */ - name: string; - /** Return a DispatchAction if this rule matches, null to fall through */ - match: (ctx: DispatchContext) => Promise; -} - -function missingSliceStop(mid: string, phase: string): DispatchAction { - return { - action: "stop", - reason: `${mid}: phase "${phase}" has no active slice — run /gsd doctor.`, - level: "error", - }; -} - -/** - * Check for milestone slices missing SUMMARY files. - * Returns array of missing slice IDs, or empty array if all present or DB unavailable. - * - * Excludes skipped slices (intentionally summary-less) and legacy-complete - * slices whose DB status is authoritative even without on-disk SUMMARY (#3620). - */ -function findMissingSummaries(basePath: string, mid: string): string[] { - if (!isDbAvailable()) return []; - const slices = getMilestoneSlices(mid); - // Skipped slices never produce SUMMARYs; legacy-complete slices may lack them - const CLOSED_STATUSES = new Set(["skipped", "complete", "done"]); - return slices - .filter(s => !CLOSED_STATUSES.has(s.status)) - .filter(s => { - const summaryPath = resolveSliceFile(basePath, mid, s.id, "SUMMARY"); - return !summaryPath || !existsSync(summaryPath); - }) - .map(s => s.id); -} - -// ─── Rewrite Circuit Breaker ────────────────────────────────────────────── - -const MAX_REWRITE_ATTEMPTS = 3; - -// ─── Disk-persisted rewrite attempt counter ────────────────────────────────── -// The counter must survive session restarts (crash recovery, pause/resume, -// step-mode). Storing it on the in-memory session object caused the circuit -// breaker to never trip — see https://github.com/singularity-forge/sf-run/issues/2203 -function rewriteCountPath(basePath: string): string { - return join(gsdRoot(basePath), "runtime", "rewrite-count.json"); -} - -export function getRewriteCount(basePath: string): number { - try { - const data = JSON.parse(readFileSync(rewriteCountPath(basePath), "utf-8")); - return typeof data.count === "number" ? data.count : 0; - } catch { - return 0; - } -} - -export function setRewriteCount(basePath: string, count: number): void { - const filePath = rewriteCountPath(basePath); - mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); - writeFileSync(filePath, JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n"); -} - -// ─── Run-UAT dispatch counter (per-slice) ──────────────────────────────── -// Caps run-uat dispatches to prevent infinite replay when verification -// commands fail before writing a verdict (#3624). -const MAX_UAT_ATTEMPTS = 3; - -function uatCountPath(basePath: string, mid: string, sid: string): string { - return join(gsdRoot(basePath), "runtime", `uat-count-${mid}-${sid}.json`); -} - -export function getUatCount(basePath: string, mid: string, sid: string): number { - try { - const data = JSON.parse(readFileSync(uatCountPath(basePath, mid, sid), "utf-8")); - return typeof data.count === "number" ? data.count : 0; - } catch { - return 0; - } -} - -export function incrementUatCount(basePath: string, mid: string, sid: string): number { - const count = getUatCount(basePath, mid, sid) + 1; - const filePath = uatCountPath(basePath, mid, sid); - mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); - writeFileSync(filePath, JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n"); - return count; -} - -// ─── Helpers ───────────────────────────────────────────────────────────── - -/** - * Returns true when the verification_operational value indicates that no - * operational verification is needed. Covers common phrasings the planning - * agent may use: "None", "None required", "N/A", "Not applicable", etc. - * - * @see https://github.com/singularity-forge/sf-run/issues/2931 - */ -export function isVerificationNotApplicable(value: string): boolean { - const v = (value ?? "").toLowerCase().trim().replace(/[.\s]+$/, ""); - if (!v || v === "none") return true; - return /^(?:none(?:[\s._\u2014-]+[\s\S]*)?|n\/?a|not[\s._-]+(?:applicable|required|needed|provided)|no[\s._-]+operational[\s\S]*)$/i.test(v); -} - -// ─── Rules ──────────────────────────────────────────────────────────────── - -export const DISPATCH_RULES: DispatchRule[] = [ - { - name: "rewrite-docs (override gate)", - match: async ({ mid, midTitle, state, basePath, session }) => { - const pendingOverrides = await loadActiveOverrides(basePath); - if (pendingOverrides.length === 0) return null; - const count = getRewriteCount(basePath); - if (count >= MAX_REWRITE_ATTEMPTS) { - const { resolveAllOverrides } = await import("./files.js"); - await resolveAllOverrides(basePath); - setRewriteCount(basePath, 0); - return null; - } - setRewriteCount(basePath, count + 1); - const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid; - return { - action: "dispatch", - unitType: "rewrite-docs", - unitId, - prompt: await buildRewriteDocsPrompt( - mid, - midTitle, - state.activeSlice, - basePath, - pendingOverrides, - ), - }; - }, - }, - { - name: "summarizing → complete-slice", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "summarizing") return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - return { - action: "dispatch", - unitType: "complete-slice", - unitId: `${mid}/${sid}`, - prompt: await buildCompleteSlicePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - ), - }; - }, - }, - { - name: "run-uat (post-completion)", - match: async ({ state, mid, basePath, prefs }) => { - const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs); - if (!needsRunUat) return null; - const { sliceId, uatType } = needsRunUat; - - // Cap run-uat dispatch attempts to prevent infinite replay (#3624) - const attempts = incrementUatCount(basePath, mid, sliceId); - if (attempts > MAX_UAT_ATTEMPTS) { - return { - action: "stop" as const, - reason: `run-uat for ${mid}/${sliceId} has been dispatched ${attempts - 1} times without producing a verdict. Verification commands may be broken — fix the UAT spec or manually write an ASSESSMENT verdict.`, - level: "warning" as const, - }; - } - const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; - const uatContent = await loadFile(uatFile); - return { - action: "dispatch", - unitType: "run-uat", - unitId: `${mid}/${sliceId}`, - prompt: await buildRunUatPrompt( - mid, - sliceId, - relSliceFile(basePath, mid, sliceId, "UAT"), - uatContent ?? "", - basePath, - ), - pauseAfterDispatch: !process.env.SF_HEADLESS && uatType !== "artifact-driven" && uatType !== "browser-executable" && uatType !== "runtime-executable", - }; - }, - }, - { - name: "uat-verdict-gate (non-PASS blocks progression)", - match: async ({ mid, basePath, prefs }) => { - // Only applies when UAT dispatch is enabled - if (!prefs?.uat_dispatch) return null; - - const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); - - // DB-first: get completed slices from DB - let completedSliceIds: string[]; - if (isDbAvailable()) { - completedSliceIds = getMilestoneSlices(mid) - .filter(s => s.status === "complete") - .map(s => s.id); - } else { - return null; - } - - for (const sliceId of completedSliceIds) { - const resultFile = resolveSliceFile(basePath, mid, sliceId, "UAT"); - if (!resultFile) continue; - const content = await loadFile(resultFile); - if (!content) continue; - const verdict = extractVerdict(content); - const uatType = extractUatType(content); - - if (verdict && !isAcceptableUatVerdict(verdict, uatType)) { - return { - action: "stop" as const, - reason: `UAT verdict for ${sliceId} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`, - level: "warning" as const, - }; - } - } - return null; - }, - }, - { - name: "reassess-roadmap (post-completion)", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (prefs?.phases?.skip_reassess) return null; - // Default reassess_after_slice to true — reassessment after slice completion - // is essential for roadmap integrity. Opt-out via explicit `false`. - const reassessEnabled = prefs?.phases?.reassess_after_slice ?? true; - if (!reassessEnabled) return null; - const needsReassess = await checkNeedsReassessment(basePath, mid, state); - if (!needsReassess) return null; - return { - action: "dispatch", - unitType: "reassess-roadmap", - unitId: `${mid}/${needsReassess.sliceId}`, - prompt: await buildReassessRoadmapPrompt( - mid, - midTitle, - needsReassess.sliceId, - basePath, - ), - }; - }, - }, - { - name: "needs-discussion → discuss-milestone", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "needs-discussion") return null; - return { - action: "dispatch", - unitType: "discuss-milestone", - unitId: mid, - prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - name: "pre-planning (no context) → discuss-milestone", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "pre-planning") return null; - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const hasContext = !!(contextFile && (await loadFile(contextFile))); - if (hasContext) return null; // fall through to next rule - return { - action: "dispatch", - unitType: "discuss-milestone", - unitId: mid, - prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - name: "pre-planning (no research) → research-milestone", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "pre-planning") return null; - // Phase skip: skip research when preference or profile says so - if (prefs?.phases?.skip_research) return null; - const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); - if (researchFile) return null; // has research, fall through - return { - action: "dispatch", - unitType: "research-milestone", - unitId: mid, - prompt: await buildResearchMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - name: "pre-planning (has research) → plan-milestone", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "pre-planning") return null; - return { - action: "dispatch", - unitType: "plan-milestone", - unitId: mid, - prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - // Keep this rule before the single-slice research rule so the multi-slice - // path wins whenever 2+ slices are ready. - name: "planning (multiple slices need research) → parallel-research-slices", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "planning") return null; - if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null; - - // Load roadmap to find all slices - const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - if (!roadmapContent) return null; - const roadmap = parseRoadmap(roadmapContent); - - // Find slices that need research (no RESEARCH file, dependencies done) - const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); - const researchReadySlices: Array<{ id: string; title: string }> = []; - - for (const slice of roadmap.slices) { - if (slice.done) continue; - // Skip S01 when milestone research exists - if (milestoneResearchFile && slice.id === "S01") continue; - // Skip if already has research - if (resolveSliceFile(basePath, mid, slice.id, "RESEARCH")) continue; - // Skip if dependencies aren't done (check for SUMMARY files) - const depsComplete = (slice.depends ?? []).every((depId) => - !!resolveSliceFile(basePath, mid, depId, "SUMMARY"), - ); - if (!depsComplete) continue; - - researchReadySlices.push({ id: slice.id, title: slice.title }); - } - - // Only dispatch parallel if 2+ slices are ready - if (researchReadySlices.length < 2) return null; - - return { - action: "dispatch", - unitType: "research-slice", - unitId: `${mid}/parallel-research`, - prompt: await buildParallelResearchSlicesPrompt( - mid, - midTitle, - researchReadySlices, - basePath, - resolveModelWithFallbacksForUnit("subagent")?.primary, - ), - }; - }, - }, - { - name: "planning (no research, not S01) → research-slice", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "planning") return null; - // Phase skip: skip research when preference or profile says so - if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) - return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); - if (researchFile) return null; // has research, fall through - // Skip slice research for S01 when milestone research already exists — - // the milestone research already covers the same ground for the first slice. - const milestoneResearchFile = resolveMilestoneFile( - basePath, - mid, - "RESEARCH", - ); - if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice - return { - action: "dispatch", - unitType: "research-slice", - unitId: `${mid}/${sid}`, - prompt: await buildResearchSlicePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - ), - }; - }, - }, - { - name: "planning → plan-slice", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "planning") return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - return { - action: "dispatch", - unitType: "plan-slice", - unitId: `${mid}/${sid}`, - prompt: await buildPlanSlicePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - ), - }; - }, - }, - { - name: "evaluating-gates → gate-evaluate", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "evaluating-gates") return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice.id; - const sTitle = state.activeSlice.title; - - // Gate evaluation is opt-in via preferences - const gateConfig = prefs?.gate_evaluation; - if (!gateConfig?.enabled) { - markAllGatesOmitted(mid, sid); - return { action: "skip" }; - } - - const pending = getPendingGates(mid, sid, "slice"); - if (pending.length === 0) return { action: "skip" }; - - return { - action: "dispatch", - unitType: "gate-evaluate", - unitId: `${mid}/${sid}/gates+${pending.map(g => g.gate_id).join(",")}`, - prompt: await buildGateEvaluatePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - resolveModelWithFallbacksForUnit("subagent")?.primary, - ), - }; - }, - }, - { - name: "replanning-slice → replan-slice", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "replanning-slice") return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - return { - action: "dispatch", - unitType: "replan-slice", - unitId: `${mid}/${sid}`, - prompt: await buildReplanSlicePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - ), - }; - }, - }, - { - name: "executing → reactive-execute (parallel dispatch)", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "executing" || !state.activeTask) return null; - if (!state.activeSlice) return null; // fall through - - // Only activate when reactive_execution is explicitly enabled - const reactiveConfig = prefs?.reactive_execution; - if (!reactiveConfig?.enabled) return null; - - const sid = state.activeSlice.id; - const sTitle = state.activeSlice.title; - const maxParallel = reactiveConfig.max_parallel ?? 2; - const subagentModel = reactiveConfig.subagent_model ?? resolveModelWithFallbacksForUnit("subagent")?.primary; - - // Dry-run mode: max_parallel=1 means graph is derived and logged but - // execution remains sequential - if (maxParallel <= 1) return null; - - try { - const { - loadSliceTaskIO, - deriveTaskGraph, - isGraphAmbiguous, - getReadyTasks, - chooseNonConflictingSubset, - graphMetrics, - } = await import("./reactive-graph.js"); - - const taskIO = await loadSliceTaskIO(basePath, mid, sid); - if (taskIO.length < 2) return null; // single task, no point - - const graph = deriveTaskGraph(taskIO); - - // Ambiguous graph → fall through to sequential - if (isGraphAmbiguous(graph)) return null; - - const completed = new Set(graph.filter((n) => n.done).map((n) => n.id)); - const readyIds = getReadyTasks(graph, completed, new Set()); - - // Only activate reactive dispatch when >1 task is ready - if (readyIds.length <= 1) return null; - - const uokFlags = resolveUokFlags(prefs); - const selected = uokFlags.executionGraph - ? selectReactiveDispatchBatch({ - graph, - readyIds, - maxParallel, - inFlightOutputs: new Set(), - }).selected - : chooseNonConflictingSubset( - readyIds, - graph, - maxParallel, - new Set(), - ); - if (selected.length <= 1) return null; - - // Log graph metrics for observability - const metrics = graphMetrics(graph); - process.stderr.write( - `gsd-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` + - `ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`, - ); - - // Persist dispatched batch so verification and recovery can check - // exactly which tasks were sent. - const { saveReactiveState } = await import("./reactive-graph.js"); - saveReactiveState(basePath, mid, sid, { - sliceId: sid, - completed: [...completed], - dispatched: selected, - graphSnapshot: metrics, - updatedAt: new Date().toISOString(), - }); - - // Encode selected task IDs in unitId for artifact verification. - // Format: M001/S01/reactive+T02,T03 - const batchSuffix = selected.join(","); - - return { - action: "dispatch", - unitType: "reactive-execute", - unitId: `${mid}/${sid}/reactive+${batchSuffix}`, - prompt: await buildReactiveExecutePrompt( - mid, - midTitle, - sid, - sTitle, - selected, - basePath, - subagentModel, - ), - }; - } catch (err) { - // Non-fatal — fall through to sequential execution - logError("dispatch", "reactive graph derivation failed", { error: (err as Error).message }); - return null; - } - }, - }, - { - name: "executing → execute-task (recover missing task plan → plan-slice)", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "executing" || !state.activeTask) return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const tid = state.activeTask.id; - - // Guard: if the slice plan exists but the individual task plan files are - // missing, the planner created S##-PLAN.md with task entries but never - // wrote the tasks/ directory files. Dispatch plan-slice to regenerate - // them rather than hard-stopping — fixes the infinite-loop described in - // issue #909. - const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); - if (!taskPlanPath || !existsSync(taskPlanPath)) { - return { - action: "dispatch", - unitType: "plan-slice", - unitId: `${mid}/${sid}`, - prompt: await buildPlanSlicePrompt( - mid, - midTitle, - sid, - sTitle, - basePath, - ), - }; - } - - return null; - }, - }, - { - name: "executing → execute-task", - match: async ({ state, mid, basePath }) => { - if (state.phase !== "executing" || !state.activeTask) return null; - if (!state.activeSlice) return missingSliceStop(mid, state.phase); - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const tid = state.activeTask.id; - const tTitle = state.activeTask.title; - - return { - action: "dispatch", - unitType: "execute-task", - unitId: `${mid}/${sid}/${tid}`, - prompt: await buildExecuteTaskPrompt( - mid, - sid, - sTitle, - tid, - tTitle, - basePath, - ), - }; - }, - }, - { - name: "validating-milestone → validate-milestone", - match: async ({ state, mid, midTitle, basePath, prefs }) => { - if (state.phase !== "validating-milestone") return null; - - // Safety guard (#1368): verify all roadmap slices have SUMMARY files before - // allowing milestone validation. - const missingSlices = findMissingSummaries(basePath, mid); - if (missingSlices.length > 0) { - return { - action: "stop", - reason: `Cannot validate milestone ${mid}: slices ${missingSlices.join(", ")} are missing SUMMARY files. These slices may have been skipped.`, - level: "error", - }; - } - - // Skip preference: write a minimal pass-through VALIDATION file - if (prefs?.phases?.skip_milestone_validation) { - const mDir = resolveMilestonePath(basePath, mid); - if (mDir) { - if (!existsSync(mDir)) mkdirSync(mDir, { recursive: true }); - const validationPath = join( - mDir, - buildMilestoneFileName(mid, "VALIDATION"), - ); - const content = [ - "---", - "verdict: pass", - "remediation_round: 0", - "---", - "", - "# Milestone Validation (skipped by preference)", - "", - "Milestone validation was skipped via `skip_milestone_validation` preference.", - ].join("\n"); - writeFileSync(validationPath, content, "utf-8"); - } - return { action: "skip" }; - } - return { - action: "dispatch", - unitType: "validate-milestone", - unitId: mid, - prompt: await buildValidateMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - name: "completing-milestone → complete-milestone", - match: async ({ state, mid, midTitle, basePath }) => { - if (state.phase !== "completing-milestone") return null; - - // Safety guard (#2675): block completion when VALIDATION verdict is - // needs-remediation. The state machine treats needs-remediation as - // terminal (to prevent validate-milestone loops per #832), but - // completing-milestone should NOT proceed — remediation work is needed. - const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION"); - if (validationFile) { - const validationContent = await loadFile(validationFile); - if (validationContent) { - const verdict = extractVerdict(validationContent); - if (verdict === "needs-remediation") { - return { - action: "stop", - reason: `Cannot complete milestone ${mid}: VALIDATION verdict is "needs-remediation". Address the remediation findings and re-run validation, or update the verdict manually.`, - level: "warning", - }; - } - } - } - - // Safety guard (#1368): verify all roadmap slices have SUMMARY files. - const missingSlices = findMissingSummaries(basePath, mid); - if (missingSlices.length > 0) { - return { - action: "stop", - reason: `Cannot complete milestone ${mid}: slices ${missingSlices.join(", ")} are missing SUMMARY files. Run /gsd doctor to diagnose.`, - level: "error", - }; - } - - // Safety guard (#1703): verify the milestone produced implementation - // artifacts (non-.gsd/ files). A milestone with only plan files and - // zero implementation code should not be marked complete. - const artifactCheck = hasImplementationArtifacts(basePath); - if (artifactCheck === "absent") { - return { - action: "stop", - reason: `Cannot complete milestone ${mid}: no implementation files found outside .gsd/. The milestone has only plan files — actual code changes are required.`, - level: "error", - }; - } - if (artifactCheck === "unknown") { - logWarning("dispatch", `Implementation artifact check inconclusive for ${mid} — proceeding (git context unavailable)`); - } - - // Verification class compliance: if operational verification was planned, - // ensure the validation output documents it before allowing completion. - try { - if (isDbAvailable()) { - const milestone = getMilestone(mid); - if (milestone?.verification_operational && - !isVerificationNotApplicable(milestone.verification_operational)) { - const validationPath = resolveMilestoneFile(basePath, mid, "VALIDATION"); - if (validationPath) { - const validationContent = await loadFile(validationPath); - if (validationContent) { - // Allow completion when validation was intentionally skipped by - // preference/budget profile (#3399, #3344). - const skippedByPreference = /skip(?:ped)?[\s\-]+(?:by|per|due to)\s+(?:preference|budget|profile)/i.test(validationContent); - - // Accept either the structured template format (table with MET/N/A/SATISFIED) - // or prose evidence patterns the validation agent may emit. - const structuredMatch = - validationContent.includes("Operational") && - (validationContent.includes("MET") || validationContent.includes("N/A") || validationContent.includes("SATISFIED")); - const proseMatch = - /[Oo]perational[\s\S]{0,500}?(?:✅|pass|verified|confirmed|met|complete|true|yes|addressed|covered|satisfied|partially|n\/a|not[\s-]+applicable)/i.test(validationContent); - const hasOperationalCheck = skippedByPreference || structuredMatch || proseMatch; - if (!hasOperationalCheck) { - return { - action: "stop" as const, - reason: `Milestone ${mid} has planned operational verification ("${milestone.verification_operational.substring(0, 100)}") but the validation output does not address it. Re-run validation with verification class awareness, or update the validation to document operational compliance.`, - level: "warning" as const, - }; - } - } - } - } - } - } catch (err) { /* fall through — don't block on DB errors */ - logWarning("dispatch", `verification class check failed: ${err instanceof Error ? err.message : String(err)}`); - } - - return { - action: "dispatch", - unitType: "complete-milestone", - unitId: mid, - prompt: await buildCompleteMilestonePrompt(mid, midTitle, basePath), - }; - }, - }, - { - name: "complete → stop", - match: async ({ state }) => { - if (state.phase !== "complete") return null; - return { - action: "stop", - reason: "All milestones complete.", - level: "info", - }; - }, - }, -]; - -import { getRegistry } from "./rule-registry.js"; - -// ─── Resolver ───────────────────────────────────────────────────────────── - -/** - * Evaluate dispatch rules in order. Returns the first matching action, - * or a "stop" action if no rule matches (unhandled phase). - * - * Delegates to the RuleRegistry when initialized; falls back to inline - * loop over DISPATCH_RULES for backward compatibility (tests that import - * resolveDispatch directly without registry initialization). - */ -export async function resolveDispatch( - ctx: DispatchContext, -): Promise { - // Delegate to registry when available - try { - const registry = getRegistry(); - return await registry.evaluateDispatch(ctx); - } catch (err) { - // Registry not initialized — fall back to inline loop - logWarning("dispatch", `registry dispatch failed, falling back to inline rules: ${err instanceof Error ? err.message : String(err)}`); - } - - for (const rule of DISPATCH_RULES) { - const result = await rule.match(ctx); - if (result) { - if (result.action !== "skip") result.matchedRule = rule.name; - return result; - } - } - - // No rule matched — unhandled phase. - // Use level "warning" so the loop pauses (resumable) instead of hard-stopping. - // Hard-stop here was causing premature termination for transient phase gaps - // (e.g. after reassessment modifies the roadmap and state needs re-derivation). - return { - action: "stop", - reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`, - level: "warning", - matchedRule: "", - }; -} - -/** Exposed for testing — returns the rule names in evaluation order. */ -export function getDispatchRuleNames(): string[] { - return DISPATCH_RULES.map((r) => r.name); -} diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts deleted file mode 100644 index 6400e9871..000000000 --- a/src/resources/extensions/gsd/auto-loop.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * auto-loop.ts — Barrel re-export for the auto-loop pipeline modules. - * - * The implementation has been split into focused modules under auto/. - * This file preserves the original public API so external consumers - * (auto.ts, auto-timeout-recovery.ts, agent-end-recovery.ts, tests) - * continue to work without changes. - */ - -export { autoLoop } from "./auto/loop.js"; -export { isInfrastructureError, INFRA_ERROR_CODES } from "./auto/infra-errors.js"; -export { resolveAgentEnd, resolveAgentEndCancelled, isSessionSwitchInFlight, _resetPendingResolve, _setActiveSession } from "./auto/resolve.js"; -export { detectStuck } from "./auto/detect-stuck.js"; -export { runUnit } from "./auto/run-unit.js"; -export type { LoopDeps } from "./auto/loop-deps.js"; -export type { AgentEndEvent, ErrorContext, UnitResult } from "./auto/types.js"; diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts deleted file mode 100644 index 7a640345d..000000000 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * Model selection and dynamic routing for auto-mode unit dispatch. - * Handles complexity-based routing, model resolution across providers, - * and fallback chains. - */ - -import type { Api, Model } from "@sf-run/pi-ai"; -import { getProviderCapabilities } from "@sf-run/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; -import type { GSDPreferences } from "./preferences.js"; -import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig, resolvePersistModelChanges } from "./preferences.js"; -import type { ComplexityTier } from "./complexity-classifier.js"; -import { classifyUnitComplexity, extractTaskMetadata, tierLabel } from "./complexity-classifier.js"; -import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides, adjustToolSet, filterToolsForProvider } from "./model-router.js"; -import { getLedger, getProjectTotals } from "./metrics.js"; -import { unitPhaseLabel } from "./auto-dashboard.js"; -import { getSessionModelOverride } from "./session-model-override.js"; -import { logWarning } from "./workflow-logger.js"; -import { resolveUokFlags } from "./uok/flags.js"; -import { applyModelPolicyFilter } from "./uok/model-policy.js"; - -export interface ModelSelectionResult { - /** Routing metadata for metrics recording */ - routing: { tier: string; modelDowngraded: boolean } | null; - /** Concrete model applied before dispatch so it can be restored after a fresh session. */ - appliedModel: Model | null; -} - -export function resolvePreferredModelConfig( - unitType: string, - autoModeStartModel: { provider: string; id: string; flatRateCtx?: FlatRateContext } | null, - isAutoMode = true, -) { - const explicitConfig = resolveModelWithFallbacksForUnit(unitType); - if (explicitConfig) return explicitConfig; - - // In interactive mode, don't synthesize a routing-based model config. - // The user's session model (/model) should be used as-is (#3962). - if (!isAutoMode) return undefined; - - const routingConfig = resolveDynamicRoutingConfig(); - if (!routingConfig.enabled || !routingConfig.tier_models) return undefined; - - // Don't synthesize a routing config for flat-rate providers (#3453). - if (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx)) return undefined; - - const ceilingModel = routingConfig.tier_models.heavy - ?? (autoModeStartModel ? `${autoModeStartModel.provider}/${autoModeStartModel.id}` : undefined); - if (!ceilingModel) return undefined; - - return { - primary: ceilingModel, - fallbacks: [], - }; -} - -/** - * Select and apply the appropriate model for a unit dispatch. - * Handles: per-unit-type model preferences, dynamic complexity routing, - * provider/model resolution, fallback chains, and start-model re-application. - * - * Returns routing metadata for metrics tracking. - */ -export async function selectAndApplyModel( - ctx: ExtensionContext, - pi: ExtensionAPI, - unitType: string, - unitId: string, - basePath: string, - prefs: GSDPreferences | undefined, - verbose: boolean, - autoModeStartModel: { provider: string; id: string; flatRateCtx?: FlatRateContext } | null, - retryContext?: { isRetry: boolean; previousTier?: string }, - /** When false (interactive/guided-flow), skip dynamic routing and use the session model. - * Dynamic routing only applies in auto-mode where cost optimization is expected. (#3962) */ - isAutoMode = true, - /** Explicit /gsd model pin captured at bootstrap for long-running auto loops. */ - sessionModelOverride?: { provider: string; id: string } | null, -): Promise { - const uokFlags = resolveUokFlags(prefs); - const persistModelChanges = resolvePersistModelChanges(); - const effectiveSessionModelOverride = sessionModelOverride === undefined - ? getSessionModelOverride(ctx.sessionManager.getSessionId()) - : (sessionModelOverride ?? undefined); - // Enrich the start model with a flat-rate context up front so routing - // synthesis and the dispatch-time guard see the same signals (built-in - // list + user `flat_rate_providers` preference + externalCli auto- - // detection). The dispatch-time primary-model check below builds its - // own per-provider context when it has a resolved primary model. - if (autoModeStartModel) { - autoModeStartModel = { - ...autoModeStartModel, - flatRateCtx: buildFlatRateContext(autoModeStartModel.provider, ctx, prefs), - }; - } - const modelConfig = effectiveSessionModelOverride - ? undefined - : resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode); - let routing: { tier: string; modelDowngraded: boolean } | null = null; - let appliedModel: Model | null = null; - - if (modelConfig) { - const availableModels = ctx.modelRegistry.getAvailable(); - const modelPolicyTraceId = `model:${ctx.sessionManager.getSessionId()}:${Date.now()}`; - const modelPolicyTurnId = `${unitType}:${unitId}`; - let policyAllowedModelKeys: Set | null = null; - - // ─── Dynamic Model Routing ───────────────────────────────────────── - // Dynamic routing (complexity-based downgrading) only applies in auto-mode. - // Interactive/guided-flow dispatches use the user's session model directly, - // respecting their /model selection without silent downgrades (#3962). - const routingConfig = resolveDynamicRoutingConfig(); - if (!isAutoMode) { - routingConfig.enabled = false; - } - // burn-max defaults to quality-first dispatch (no downgrade routing). - if (prefs?.token_profile === "burn-max") { - routingConfig.enabled = false; - } - let effectiveModelConfig = modelConfig; - let routingTierLabel = ""; - let routingEligibleModels = availableModels; - - const taskMetadataForPolicy = unitType === "execute-task" - ? extractTaskMetadata(unitId, basePath) - : undefined; - - if (uokFlags.modelPolicy) { - const policy = applyModelPolicyFilter( - availableModels, - { - basePath, - traceId: modelPolicyTraceId, - turnId: modelPolicyTurnId, - unitType, - taskMetadata: taskMetadataForPolicy, - currentProvider: ctx.model?.provider, - allowCrossProvider: routingConfig.cross_provider !== false, - requiredTools: pi.getActiveTools(), - }, - ); - routingEligibleModels = policy.eligible; - policyAllowedModelKeys = new Set( - policy.eligible.map((m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}`), - ); - if (routingEligibleModels.length === 0) { - throw new Error(`Model policy denied all candidate models for ${unitType}/${unitId}`); - } - } - - // Disable routing for flat-rate providers like GitHub Copilot (#3453). - // All models cost the same per request, so downgrading to a cheaper - // model provides no cost benefit — it only degrades quality. - // Fail-closed: if primary model can't be resolved, fall back to - // provider-level signals rather than allowing unwanted downgrades. - if (routingConfig.enabled) { - const primaryModel = resolveModelId(modelConfig.primary, routingEligibleModels, ctx.model?.provider); - if (primaryModel) { - const primaryFlatRateCtx = buildFlatRateContext(primaryModel.provider, ctx, prefs); - if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) { - routingConfig.enabled = false; - } - } else if ( - (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx)) - || (ctx.model?.provider && isFlatRateProvider( - ctx.model.provider, - buildFlatRateContext(ctx.model.provider, ctx, prefs), - )) - ) { - // Primary model unresolvable but provider signals indicate flat-rate — - // disable routing to prevent quality degradation. - routingConfig.enabled = false; - } - } - - if (routingConfig.enabled) { - let budgetPct: number | undefined; - if (routingConfig.budget_pressure !== false) { - const budgetCeiling = prefs?.budget_ceiling; - if (budgetCeiling !== undefined && budgetCeiling > 0) { - const currentLedger = getLedger(); - const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0; - budgetPct = totalCost / budgetCeiling; - } - } - - const isHook = unitType.startsWith("hook/"); - const shouldClassify = !isHook || routingConfig.hooks !== false; - - if (shouldClassify) { - let classification = classifyUnitComplexity( - unitType, - unitId, - basePath, - budgetPct, - taskMetadataForPolicy, - ); - const availableModelIds = routingEligibleModels.map(m => m.id); - - // Escalate tier on retry when escalate_on_failure is enabled (default: true) - if ( - retryContext?.isRetry && - retryContext.previousTier && - routingConfig.escalate_on_failure !== false - ) { - const escalated = escalateTier(retryContext.previousTier as ComplexityTier); - if (escalated) { - classification = { ...classification, tier: escalated, reason: "escalated after failure" }; - // Always notify on tier escalation — model changes should be visible (#3962) - ctx.ui.notify( - `Tier escalation: ${retryContext.previousTier} → ${escalated} (retry after failure)`, - "info", - ); - } - } - - // Load user capability overrides from preferences (D-17: deep-merged with built-in profiles) - const capabilityOverrides = loadCapabilityOverrides(prefs ?? {}); - - // Fire before_model_select hook (ADR-004, D-03) - // Hook can override model selection entirely by returning { modelId } - let hookOverride: string | undefined; - if (routingConfig.hooks !== false) { - const eligible = getEligibleModels( - classification.tier, - availableModelIds, - routingConfig, - ); - const hookResult = await pi.emitBeforeModelSelect({ - unitType, - unitId, - classification: { - tier: classification.tier, - reason: classification.reason, - downgraded: classification.downgraded, - }, - taskMetadata: classification.taskMetadata as Record | undefined, - eligibleModels: eligible, - phaseConfig: modelConfig ? { - primary: modelConfig.primary, - fallbacks: modelConfig.fallbacks ?? [], - } : undefined, - }); - if (hookResult?.modelId) { - hookOverride = hookResult.modelId; - } - } - - let routingResult: ReturnType; - if (hookOverride) { - // Hook override bypasses capability scoring entirely - routingResult = { - modelId: hookOverride, - fallbacks: [ - ...(modelConfig?.fallbacks ?? []).filter(f => f !== hookOverride), - ...(modelConfig?.primary && modelConfig.primary !== hookOverride ? [modelConfig.primary] : []), - ], - tier: classification.tier, - wasDowngraded: hookOverride !== modelConfig?.primary, - reason: `hook override: ${hookOverride}`, - selectionMethod: "tier-only", - }; - } else { - routingResult = resolveModelForComplexity( - classification, - modelConfig, - routingConfig, - availableModelIds, - unitType, - classification.taskMetadata, - capabilityOverrides, - ); - } - - if (routingResult.wasDowngraded) { - effectiveModelConfig = { - primary: routingResult.modelId, - fallbacks: routingResult.fallbacks, - }; - // Always notify on model downgrade — users should see when their - // model selection is overridden, not just in verbose mode (#3962). - if (routingResult.selectionMethod === "capability-scored" && routingResult.capabilityScores) { - const tierLbl = tierLabel(classification.tier); - const scores = Object.entries(routingResult.capabilityScores) - .sort(([, a], [, b]) => b - a) - .map(([id, score]) => `${id}: ${score.toFixed(1)}`) - .join(", "); - ctx.ui.notify( - `Dynamic routing [${tierLbl}]: ${routingResult.modelId} (capability-scored) — ${scores}`, - "info", - ); - } else { - ctx.ui.notify( - `Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`, - "info", - ); - } - } - routingTierLabel = ` [${tierLabel(classification.tier)}]`; - routing = { tier: classification.tier, modelDowngraded: routingResult.wasDowngraded }; - } - } - - const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks]; - let attemptedPolicyEligible = false; - - for (const modelId of modelsToTry) { - const resolutionPool = uokFlags.modelPolicy ? routingEligibleModels : availableModels; - const model = resolveModelId(modelId, resolutionPool, ctx.model?.provider); - - if (!model) { - if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info"); - continue; - } - - if (policyAllowedModelKeys) { - const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`; - if (!policyAllowedModelKeys.has(key)) { - if (verbose) { - ctx.ui.notify(`Model policy denied ${model.provider}/${model.id}; trying fallback.`, "warning"); - } - continue; - } - attemptedPolicyEligible = true; - } - - // Warn if the ID is ambiguous across providers - if (!modelId.includes("/")) { - const providers = availableModels.filter(m => m.id === modelId).map(m => m.provider); - if (providers.length > 1 && model.provider !== ctx.model?.provider) { - ctx.ui.notify( - `Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` + - `Resolved to ${model.provider}. Use "provider/model" format for explicit targeting.`, - "warning", - ); - } - } - - const ok = await pi.setModel(model, { persist: persistModelChanges }); - if (ok) { - appliedModel = model; - - // ADR-005: Adjust active tool set for the selected model's provider capabilities. - // Hard-filter incompatible tools, then let extensions override via adjust_tool_set hook. - const activeToolNames = pi.getActiveTools(); - const { toolNames: compatibleTools, removedTools } = adjustToolSet(activeToolNames, model.api); - let finalToolNames = compatibleTools; - - // Fire adjust_tool_set hook — extensions can override the filtered tool set - if (routingConfig.hooks !== false) { - const hookResult = await pi.emitAdjustToolSet({ - selectedModelApi: model.api, - selectedModelProvider: model.provider, - selectedModelId: model.id, - activeToolNames, - filteredTools: removedTools, - }); - if (hookResult?.toolNames) { - finalToolNames = hookResult.toolNames; - } - } - - // Apply the filtered tool set if any tools were removed - if (removedTools.length > 0 || finalToolNames.length !== activeToolNames.length) { - pi.setActiveTools(finalToolNames); - } - - { - const fallbackNote = modelId === effectiveModelConfig.primary - ? "" - : ` (fallback from ${effectiveModelConfig.primary})`; - const phase = unitPhaseLabel(unitType); - ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info"); - } - if (verbose) { - // ADR-005: Report tools filtered due to provider incompatibility - if (removedTools.length > 0) { - ctx.ui.notify( - `Tool compatibility: ${removedTools.length} tools filtered for ${model.api} — ${removedTools.join(", ")}`, - "info", - ); - } - } - break; - } else { - const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1]; - if (nextModel) { - if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info"); - } else { - ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning"); - } - } - } - - if (uokFlags.modelPolicy && policyAllowedModelKeys && !attemptedPolicyEligible) { - throw new Error(`Model policy denied dispatch for ${unitType}/${unitId} before prompt send`); - } - } else if (autoModeStartModel) { - // No model preference for this unit type — re-apply the model captured - // at auto-mode start to prevent bleed from shared global settings.json (#650). - const availableModels = ctx.modelRegistry.getAvailable(); - const startModel = availableModels.find( - m => m.provider === autoModeStartModel.provider && m.id === autoModeStartModel.id, - ); - if (startModel) { - const ok = await pi.setModel(startModel, { persist: persistModelChanges }); - if (!ok) { - const byId = availableModels.find(m => m.id === autoModeStartModel.id); - if (byId) { - const fallbackOk = await pi.setModel(byId, { persist: persistModelChanges }); - if (fallbackOk) appliedModel = byId; - } - } else { - appliedModel = startModel; - } - } - } - - return { routing, appliedModel }; -} - -/** - * Resolve a model ID string to a model object from the available models list. - * Handles formats: "provider/model", "bare-id", "org/model-name" (OpenRouter). - */ -export function resolveModelId( - modelId: string, - availableModels: T[], - currentProvider: string | undefined, -): T | undefined { - const slashIdx = modelId.indexOf("/"); - - if (slashIdx !== -1) { - const maybeProvider = modelId.substring(0, slashIdx); - const id = modelId.substring(slashIdx + 1); - - const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase())); - if (knownProviders.has(maybeProvider.toLowerCase())) { - const match = availableModels.find( - m => m.provider.toLowerCase() === maybeProvider.toLowerCase() - && m.id.toLowerCase() === id.toLowerCase(), - ); - if (match) return match; - } - - // Try matching the full string as a model ID (OpenRouter-style) - const lower = modelId.toLowerCase(); - return availableModels.find( - m => m.id.toLowerCase() === lower - || `${m.provider}/${m.id}`.toLowerCase() === lower, - ); - } - - // Bare ID — resolve with provider precedence to avoid silent misrouting. - // Extension providers (e.g. claude-code) expose the same model IDs as their - // upstream API providers but route through a subprocess with different - // context, tool visibility, and cost characteristics (#2905). Bare IDs in - // PREFERENCES.md must resolve to the canonical API provider, not to an - // extension wrapper that happens to be the current session provider. - const candidates = availableModels.filter(m => m.id === modelId); - if (candidates.length === 0) return undefined; - if (candidates.length === 1) return candidates[0]; - - // When the user's current provider is claude-code (set by startup migration - // or explicit selection), honour it for bare IDs. Routing back to anthropic - // would undo the migration and hit the third-party subscription block (#3772). - if (currentProvider === "claude-code") { - const ccMatch = candidates.find(m => m.provider === "claude-code"); - if (ccMatch) return ccMatch; - } - - // Extension / CLI-wrapper providers that should not win bare-ID resolution - // when a first-class API provider also offers the same model AND the user - // has not explicitly chosen the extension provider. - const EXTENSION_PROVIDERS = new Set(["claude-code"]); - - // Prefer currentProvider only when it is a first-class API provider - if (currentProvider && !EXTENSION_PROVIDERS.has(currentProvider)) { - const providerMatch = candidates.find(m => m.provider === currentProvider); - if (providerMatch) return providerMatch; - } - - // Prefer "anthropic" as the canonical provider for Anthropic models - const anthropicMatch = candidates.find(m => m.provider === "anthropic"); - if (anthropicMatch) return anthropicMatch; - - // Fall back to first non-extension candidate, or any candidate - return candidates.find(m => !EXTENSION_PROVIDERS.has(m.provider)) ?? candidates[0]; -} - -/** - * Flat-rate providers charge the same per request regardless of model. - * Dynamic routing provides no cost benefit — it only degrades quality (#3453). - * Uses case-insensitive matching with alias support to prevent fail-open on - * provider naming variations (e.g. "copilot" vs "github-copilot"). - */ -const BUILTIN_FLAT_RATE = new Set(["github-copilot", "copilot", "claude-code"]); - -/** - * Optional context that lets callers extend flat-rate detection beyond the - * hard-coded built-in list. Either signal on its own is enough to classify - * a provider as flat-rate. - */ -export interface FlatRateContext { - /** - * Auth mode for the specific provider being checked, as returned by - * `ctx.modelRegistry.getProviderAuthMode(provider)`. Any provider that - * wraps a local CLI (externalCli) is, by definition, a flat-rate - * subscription wrapper — every request costs the same regardless of - * model, so dynamic routing only degrades quality. - */ - authMode?: "apiKey" | "oauth" | "externalCli" | "none"; - /** - * Case-insensitive list of extra provider IDs the user has declared as - * flat-rate via `preferences.flat_rate_providers`. Used for private - * subscription-backed proxies and enterprise-gated deployments that the - * built-in list doesn't know about. - */ - userFlatRate?: readonly string[]; -} - -export function isFlatRateProvider(provider: string, opts?: FlatRateContext): boolean { - const p = provider.toLowerCase(); - if (BUILTIN_FLAT_RATE.has(p)) return true; - if (opts?.userFlatRate?.some(id => id.toLowerCase() === p)) return true; - if (opts?.authMode === "externalCli") return true; - return false; -} - -/** - * Build a FlatRateContext for a given provider from live runtime state. - * Safe to call when ctx or prefs are undefined — missing pieces are - * treated as "no signal". - */ -export function buildFlatRateContext( - provider: string, - ctx?: { modelRegistry?: { getProviderAuthMode?: (p: string) => string } }, - prefs?: { flat_rate_providers?: readonly string[] }, -): FlatRateContext { - let authMode: FlatRateContext["authMode"]; - const getAuthMode = ctx?.modelRegistry?.getProviderAuthMode; - if (typeof getAuthMode === "function") { - try { - const mode = getAuthMode(provider); - if (mode === "apiKey" || mode === "oauth" || mode === "externalCli" || mode === "none") { - authMode = mode; - } - } catch (err) { - // Registry lookup failure must never break flat-rate detection — - // fall through with authMode undefined and surface the cause. - logWarning( - "dispatch", - `flat-rate auth-mode lookup failed for ${provider}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - return { - authMode, - userFlatRate: prefs?.flat_rate_providers, - }; -} diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts deleted file mode 100644 index aa18fe0fe..000000000 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ /dev/null @@ -1,1296 +0,0 @@ -/** - * Post-unit processing for handleAgentEnd — auto-commit, doctor run, - * state rebuild, worktree sync, DB dual-write, hooks, triage, and - * quick-task dispatch. - * - * Split into two functions called sequentially by handleAgentEnd with - * the verification gate between them: - * 1. postUnitPreVerification() — commit, doctor, state rebuild, worktree sync, artifact verification - * 2. postUnitPostVerification() — DB dual-write, hooks, triage, quick-tasks - * - * Extracted from handleAgentEnd() in auto.ts. - */ - -import type { ExtensionContext, ExtensionAPI } from "@sf-run/pi-coding-agent"; -import { deriveState } from "./state.js"; -import { logWarning, logError } from "./workflow-logger.js"; -import { loadFile, parseSummary, resolveAllOverrides } from "./files.js"; -import { loadPrompt } from "./prompt-loader.js"; -import { - resolveSliceFile, - resolveSlicePath, - resolveTaskFile, - resolveMilestoneFile, - resolveTasksDir, - buildTaskFileName, -} from "./paths.js"; -import { invalidateAllCaches } from "./cache.js"; -import { rebuildState } from "./doctor.js"; -import { parseUnitId } from "./unit-id.js"; -import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; -import { - runTurnGitAction, - type TaskCommitContext, - type TurnGitActionMode, -} from "./git-service.js"; -import { - verifyExpectedArtifact, - resolveExpectedArtifactPath, - writeBlockerPlaceholder, - diagnoseExpectedArtifact, -} from "./auto-recovery.js"; -import { regenerateIfMissing } from "./workflow-projections.js"; -import { syncStateToProjectRoot } from "./auto-worktree.js"; -import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, updateSliceStatus, _getAdapter } from "./gsd-db.js"; -import { renderPlanCheckboxes } from "./markdown-renderer.js"; -import { consumeSignal } from "./session-status-io.js"; -import { - checkPostUnitHooks, - isRetryPending, - consumeRetryTrigger, - persistHookState, - resolveHookArtifactPath, -} from "./post-unit-hooks.js"; -import { hasPendingCaptures, loadPendingCaptures, revertExecutorResolvedCaptures } from "./captures.js"; -import { debugLog } from "./debug-logger.js"; -import { runSafely } from "./auto-utils.js"; -import type { AutoSession, SidecarItem } from "./auto/session.js"; -import { getEvidence } from "./safety/evidence-collector.js"; -import { validateFileChanges } from "./safety/file-change-validator.js"; -// crossReferenceEvidence available for future use when verification_evidence is stored in DB -// import { crossReferenceEvidence, type ClaimedEvidence } from "./safety/evidence-cross-ref.js"; -import { validateContent } from "./safety/content-validator.js"; -import { resolveSafetyHarnessConfig } from "./safety/safety-harness.js"; -import { resolveExpectedArtifactPath as resolveArtifactForContent } from "./auto-artifact-paths.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { getSliceTasks } from "./gsd-db.js"; -import { runPreExecutionChecks, type PreExecutionResult } from "./pre-execution-checks.js"; -import { writePreExecutionEvidence } from "./verification-evidence.js"; -import { ensureCodebaseMapFresh } from "./codebase-generator.js"; -import { resolveUokFlags } from "./uok/flags.js"; -import { UokGateRunner } from "./uok/gate-runner.js"; -import { writeTurnGitTransaction } from "./uok/gitops.js"; - -/** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */ -const MAX_VERIFICATION_RETRIES = 3; - - -/** Enqueue a sidecar item (hook, triage, or quick-task) for the main loop to - * drain via runUnit. Logs the enqueue event and notifies the UI. */ -function enqueueSidecar( - s: AutoSession, - ctx: ExtensionContext, - entry: SidecarItem, - debugExtra: Record, - notification?: string, -): "continue" { - s.sidecarQueue.push(entry); - debugLog("postUnitPostVerification", { - phase: "sidecar-enqueue", - kind: entry.kind, - unitId: entry.unitId, - ...debugExtra, - }); - if (notification) ctx.ui.notify(notification, "info"); - return "continue"; -} -/** Unit types that only touch `.gsd/` internal state files (no code changes). - * Auto-commit is skipped for these — their state files are picked up by the - * next actual task commit via `smartStage()`. */ -const LIFECYCLE_ONLY_UNITS = new Set([ - "research-milestone", "discuss-milestone", "discuss-slice", "plan-milestone", - "validate-milestone", "research-slice", "plan-slice", - "replan-slice", "complete-slice", "run-uat", - "reassess-roadmap", "rewrite-docs", -]); -import { - updateProgressWidget as _updateProgressWidget, - updateSliceProgressCache, - unitVerb, - hideFooter, - describeNextUnit, -} from "./auto-dashboard.js"; -import { existsSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { _resetHasChangesCache } from "./native-git-bridge.js"; -import { autoCommitCurrentBranch } from "./worktree.js"; - -// ─── Rogue File Detection ────────────────────────────────────────────────── - -export interface RogueFileWrite { - path: string; - unitType: string; - unitId: string; -} - -/** - * Detect summary files written directly to disk without the LLM calling - * the completion tool. A "rogue" file is one that exists on disk but has - * no corresponding DB row with status "complete". - * - * This is a safety-net diagnostic (D003). The existing migrateFromMarkdown() - * in postUnitPostVerification() eventually ingests rogue files, but explicit - * detection provides immediate diagnostics so operators know the prompt failed. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function hasNonEmptyFields(row: Record | null, fields: string[]): boolean { - if (!row) return false; - return fields.some(f => String(row[f] || "").trim().length > 0); -} - -const MILESTONE_PLANNING_FIELDS = ["title", "vision", "requirement_coverage", "boundary_map_markdown"]; -const SLICE_PLANNING_FIELDS = ["title", "demo", "risk", "depends"]; - -export function detectRogueFileWrites( - unitType: string, - unitId: string, - basePath: string, -): RogueFileWrite[] { - if (!isDbAvailable()) return []; - - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - const rogues: RogueFileWrite[] = []; - - if (unitType === "execute-task") { - if (!mid || !sid || !tid) return []; - - const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); - if (!summaryPath || !existsSync(summaryPath)) return []; - - const dbRow = getTask(mid, sid, tid); - if (!dbRow || dbRow.status !== "complete") { - rogues.push({ path: summaryPath, unitType, unitId }); - } - } else if (unitType === "complete-slice") { - if (!mid || !sid) return []; - - const summaryPath = resolveSliceFile(basePath, mid, sid, "SUMMARY"); - if (!summaryPath || !existsSync(summaryPath)) return []; - - const dbRow = getSlice(mid, sid); - if (!dbRow || dbRow.status !== "complete") { - // Auto-remediate: SUMMARY exists on disk but DB is stale — sync DB to - // match filesystem instead of reporting as rogue (#3633). - try { - updateSliceStatus(mid, sid, "complete", new Date().toISOString()); - } catch { - // If DB update fails, fall back to rogue detection so the issue is visible - rogues.push({ path: summaryPath, unitType, unitId }); - } - } - } else if (unitType === "plan-milestone") { - if (!mid) return []; - - const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) return []; - - const dbRow = getMilestone(mid); - const hasPlanningState = hasNonEmptyFields(dbRow, MILESTONE_PLANNING_FIELDS); - - if (!hasPlanningState) { - rogues.push({ path: roadmapPath, unitType, unitId }); - } - } else if (unitType === "plan-slice" || unitType === "replan-slice") { - if (!mid || !sid) return []; - - const planPath = resolveSliceFile(basePath, mid, sid, "PLAN"); - if (!planPath || !existsSync(planPath)) return []; - - const dbRow = getSlice(mid, sid); - const hasPlanningState = hasNonEmptyFields(dbRow, SLICE_PLANNING_FIELDS); - - if (!hasPlanningState) { - rogues.push({ path: planPath, unitType, unitId }); - } - - // Also check for rogue REPLAN.md - const replanPath = resolveSliceFile(basePath, mid, sid, "REPLAN"); - if (replanPath && existsSync(replanPath) && !hasPlanningState) { - rogues.push({ path: replanPath, unitType, unitId }); - } - } else if (unitType === "reassess-roadmap") { - if (!mid || !sid) return []; - - const assessPath = resolveSliceFile(basePath, mid, sid, "ASSESSMENT"); - if (!assessPath || !existsSync(assessPath)) return []; - - // Assessment file exists on disk — check if DB knows about it via the artifacts table - const adapter = _getAdapter(); - if (adapter) { - const row = adapter.prepare( - `SELECT 1 FROM artifacts WHERE path LIKE :pattern AND artifact_type = 'ASSESSMENT' LIMIT 1`, - ).get({ ":pattern": `%${sid}-ASSESSMENT.md` }); - if (!row) { - rogues.push({ path: assessPath, unitType, unitId }); - } - } - } else if (unitType === "plan-task") { - if (!mid || !sid || !tid) return []; - - const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); - if (!taskPlanPath || !existsSync(taskPlanPath)) return []; - - const dbRow = getTask(mid, sid, tid); - if (!dbRow) { - rogues.push({ path: taskPlanPath, unitType, unitId }); - } - } - - return rogues; -} - -export const STEP_COMPLETE_FALLBACK_MESSAGE = - "Step complete. Run /clear, then /gsd to continue (or /gsd auto to run continuously)."; - -export function buildStepCompleteMessage(nextState: import("./types.js").GSDState): string { - if (nextState.phase === "complete") { - return "Step complete — milestone finished. Run /gsd status to review, or start the next milestone."; - } - const next = describeNextUnit(nextState); - return `Step complete. Next: ${next.label}\n` - + `Run /clear, then /gsd to continue (or /gsd auto to run continuously).`; -} - -export interface PreVerificationOpts { - skipSettleDelay?: boolean; - skipWorktreeSync?: boolean; -} - -export interface PostUnitContext { - s: AutoSession; - ctx: ExtensionContext; - pi: ExtensionAPI; - buildSnapshotOpts: (unitType: string, unitId: string) => CloseoutOptions & Record; - lockBase: () => string; - stopAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string) => Promise; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - updateProgressWidget: (ctx: ExtensionContext, unitType: string, unitId: string, state: import("./types.js").GSDState) => void; -} - -export async function autoCommitUnit( - basePath: string, - unitType: string, - unitId: string, - ctx?: ExtensionContext, -): Promise { - try { - let taskContext: TaskCommitContext | undefined; - - if (unitType === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (mid && sid && tid) { - const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); - if (summaryPath) { - try { - const summaryContent = await loadFile(summaryPath); - if (summaryContent) { - const summary = parseSummary(summaryContent); - let ghIssueNumber: number | undefined; - try { - const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js"); - ghIssueNumber = getTaskIssueNumberForCommit(basePath, mid, sid, tid) ?? undefined; - } catch (err) { - logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - taskContext = { - taskId: `${sid}/${tid}`, - taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, - oneLiner: summary.oneLiner || undefined, - keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, - issueNumber: ghIssueNumber, - }; - } - } catch (e) { - debugLog("postUnit", { phase: "task-summary-parse", error: String(e) }); - } - } - } - } - - _resetHasChangesCache(); - - if (LIFECYCLE_ONLY_UNITS.has(unitType)) { - return null; - } - - const commitMsg = autoCommitCurrentBranch(basePath, unitType, unitId, taskContext); - if (commitMsg) { - ctx?.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); - } - return commitMsg; - } catch (e) { - debugLog("postUnit", { phase: "auto-commit", error: String(e) }); - ctx?.ui.notify(`Auto-commit failed: ${String(e).split("\n")[0]}`, "warning"); - return null; - } -} - -/** - * Pre-verification processing: parallel worker signal check, cache invalidation, - * auto-commit, doctor run, state rebuild, worktree sync, artifact verification. - * - * Returns: - * - "dispatched" — a signal caused stop/pause - * - "continue" — proceed normally - * - "retry" — artifact verification failed, s.pendingVerificationRetry set for loop re-iteration - */ -export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreVerificationOpts): Promise<"dispatched" | "continue" | "retry"> { - const { s, ctx, pi, buildSnapshotOpts, stopAuto, pauseAuto } = pctx; - - // ── Parallel worker signal check ── - const milestoneLock = process.env.SF_MILESTONE_LOCK; - if (milestoneLock) { - const signal = consumeSignal(s.basePath, milestoneLock); - if (signal) { - if (signal.signal === "stop") { - await stopAuto(ctx, pi); - return "dispatched"; - } - if (signal.signal === "pause") { - await pauseAuto(ctx, pi); - return "dispatched"; - } - } - } - - // Invalidate all caches - invalidateAllCaches(); - - // Small delay to let files settle (skipped for sidecars where latency matters more) - if (!opts?.skipSettleDelay) { - await new Promise(r => setTimeout(r, 100)); - } - - const prefs = loadEffectiveGSDPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - - // Turn-level git action (commit | snapshot | status-only) - if (s.currentUnit) { - const unit = s.currentUnit; - const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit"; - const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`; - const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`; - s.lastGitActionFailure = null; - s.lastGitActionStatus = null; - try { - let taskContext: TaskCommitContext | undefined; - - if (turnAction === "commit" && s.currentUnit.type === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); - if (mid && sid && tid) { - const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); - if (summaryPath) { - try { - const summaryContent = await loadFile(summaryPath); - if (summaryContent) { - const summary = parseSummary(summaryContent); - // Look up GitHub issue number for commit linking - let ghIssueNumber: number | undefined; - try { - const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js"); - ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined; - } catch (err) { - // GitHub sync not available — skip - logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - taskContext = { - taskId: `${sid}/${tid}`, - taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, - oneLiner: summary.oneLiner || undefined, - keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, - issueNumber: ghIssueNumber, - }; - } - } catch (e) { - debugLog("postUnit", { phase: "task-summary-parse", error: String(e) }); - } - } - } - } - - // Invalidate the nativeHasChanges cache before auto-commit (#1853). - // The cache has a 10-second TTL and is keyed by basePath. A stale - // `false` result causes autoCommit to skip staging entirely, leaving - // code files only in the working tree where they are destroyed by - // `git worktree remove --force` during teardown. - _resetHasChangesCache(); - - const skipLifecycleCommit = - turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type); - - if (skipLifecycleCommit) { - debugLog("postUnit", { - phase: "git-action-skipped", - reason: "lifecycle-only-unit", - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - } else { - const gitResult = runTurnGitAction({ - basePath: s.basePath, - action: turnAction, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - taskContext, - }); - - if (uokFlags.gitops) { - writeTurnGitTransaction({ - basePath: s.basePath, - traceId, - turnId, - unitType: unit.type, - unitId: unit.id, - stage: "publish", - action: turnAction, - push: uokFlags.gitopsTurnPush, - status: gitResult.status, - error: gitResult.error, - metadata: { - dirty: gitResult.dirty, - commitMessage: gitResult.commitMessage, - snapshotLabel: gitResult.snapshotLabel, - }, - }); - } - - if (gitResult.status === "failed") { - s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`; - s.lastGitActionStatus = "failed"; - if (uokFlags.gitops && uokFlags.gates) { - const parsed = parseUnitId(unit.id); - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "closeout-git-action", - type: "closeout", - execute: async () => ({ - outcome: "fail", - failureClass: "git", - rationale: `turn git action "${turnAction}" failed`, - findings: gitResult.error ?? "unknown git failure", - }), - }); - await gateRunner.run("closeout-git-action", { - basePath: s.basePath, - traceId, - turnId, - milestoneId: parsed.milestone ?? undefined, - sliceId: parsed.slice ?? undefined, - taskId: parsed.task ?? undefined, - unitType: unit.type, - unitId: unit.id, - }); - } - - const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`; - if (uokFlags.gitops) { - ctx.ui.notify(failureMsg, "error"); - await pauseAuto(ctx, pi); - return "dispatched"; - } - ctx.ui.notify(failureMsg, "warning"); - debugLog("postUnit", { - phase: "git-action-failed-nonblocking", - action: turnAction, - error: gitResult.error ?? "unknown error", - }); - } - - s.lastGitActionStatus = "ok"; - - if (turnAction === "commit" && gitResult.commitMessage) { - ctx.ui.notify(`Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info"); - } else if (turnAction === "snapshot" && gitResult.snapshotLabel) { - ctx.ui.notify(`Snapshot recorded: ${gitResult.snapshotLabel}`, "info"); - } - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - s.lastGitActionFailure = message; - s.lastGitActionStatus = "failed"; - debugLog("postUnit", { phase: "git-action", error: message, action: turnAction }); - ctx.ui.notify(`Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning"); - if (uokFlags.gitops) { - await pauseAuto(ctx, pi); - return "dispatched"; - } - } - - // GitHub sync (non-blocking, opt-in) - await runSafely("postUnit", "github-sync", async () => { - const { runGitHubSync } = await import("../github-sync/sync.js"); - await runGitHubSync(s.basePath, unit.type, unit.id); - }); - - // Prune dead bg-shell processes - await runSafely("postUnit", "prune-bg-shell", async () => { - const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js"); - pruneDeadProcesses(); - }); - - // Tear down browser between units to prevent Chrome process accumulation (#1733) - await runSafely("postUnit", "browser-teardown", async () => { - const { getBrowser } = await import("../browser-tools/state.js"); - if (getBrowser()) { - const { closeBrowser } = await import("../browser-tools/lifecycle.js"); - await closeBrowser(); - debugLog("postUnit", { phase: "browser-teardown", status: "closed" }); - } - }); - - // Keep the on-disk STATE.md aligned with the live derived state after - // ordinary unit completion, before any worktree state is synced back. - await runSafely("postUnit", "state-rebuild", async () => { - await rebuildState(s.basePath); - }); - - // Sync worktree state back to project root (skipped for lightweight sidecars) - if (!opts?.skipWorktreeSync && s.originalBasePath && s.originalBasePath !== s.basePath) { - await runSafely("postUnit", "worktree-sync", () => { - syncStateToProjectRoot(s.basePath, s.originalBasePath!, s.currentMilestoneId); - }); - } - - // Rewrite-docs completion - if (s.currentUnit.type === "rewrite-docs") { - await runSafely("postUnit", "rewrite-docs-resolve", async () => { - await resolveAllOverrides(s.basePath); - // Reset both disk and in-memory counters. Disk counter is authoritative - // (survives restarts); in-memory is kept in sync for the current session. - const { setRewriteCount } = await import("./auto-dispatch.js"); - setRewriteCount(s.basePath, 0); - s.rewriteAttemptCount = 0; - ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); - }); - } - - // Reactive state cleanup on slice completion - if (s.currentUnit.type === "complete-slice") { - await runSafely("postUnit", "reactive-state-cleanup", async () => { - const { milestone: mid, slice: sid } = parseUnitId(unit.id); - if (mid && sid) { - const { clearReactiveState } = await import("./reactive-graph.js"); - clearReactiveState(s.basePath, mid, sid); - } - }); - } - - // Post-triage: execute actionable resolutions - if (s.currentUnit.type === "triage-captures") { - try { - const { executeTriageResolutions } = await import("./triage-resolution.js"); - const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id ?? ""; - const sid = state.activeSlice?.id ?? ""; - - // executeTriageResolutions handles defer milestone creation even - // without an active milestone/slice (the "all milestones complete" - // scenario from #1562). inject/replan/quick-task still require mid+sid. - const triageResult = executeTriageResolutions(s.basePath, mid, sid); - - if (triageResult.injected > 0) { - ctx.ui.notify( - `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, - "info", - ); - } - if (triageResult.replanned > 0) { - ctx.ui.notify( - `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, - "info", - ); - } - if (triageResult.deferredMilestones > 0) { - ctx.ui.notify( - `Triage: created ${triageResult.deferredMilestones} deferred milestone director${triageResult.deferredMilestones === 1 ? "y" : "ies"}.`, - "info", - ); - } - if (triageResult.quickTasks.length > 0) { - for (const qt of triageResult.quickTasks) { - s.pendingQuickTasks.push(qt); - } - ctx.ui.notify( - `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, - "info", - ); - } - for (const action of triageResult.actions) { - logWarning("engine", `triage resolution: ${action}`); - } - } catch (err) { - logError("engine", "triage resolution failed", { error: (err as Error).message }); - } - } - - // Rogue file detection — safety net for LLM bypassing completion tools (D003) - try { - const rogueFiles = detectRogueFileWrites(s.currentUnit.type, s.currentUnit.id, s.basePath); - for (const rogue of rogueFiles) { - logWarning("engine", "rogue file write detected", { path: rogue.path, unitId: rogue.unitId }); - ctx.ui.notify(`Rogue file write detected: ${rogue.path}`, "warning"); - } - } catch (e) { - debugLog("postUnit", { phase: "rogue-detection", error: String(e) }); - } - - // ── Safety harness: post-unit validation ── - try { - const { loadEffectiveGSDPreferences } = await import("./preferences.js"); - const prefs = loadEffectiveGSDPreferences()?.preferences; - const safetyConfig = resolveSafetyHarnessConfig( - prefs?.safety_harness as Record | undefined, - ); - - if (safetyConfig.enabled) { - const { milestone: sMid, slice: sSid, task: sTid } = parseUnitId(s.currentUnit.id); - - // File change validation (execute-task only, after auto-commit) - if (safetyConfig.file_change_validation && s.currentUnit.type === "execute-task" && sMid && sSid && sTid && isDbAvailable()) { - try { - const taskRow = getTask(sMid, sSid, sTid); - if (taskRow) { - const expectedOutput = taskRow.expected_output ?? []; - const plannedFiles = taskRow.files ?? []; - const audit = validateFileChanges(s.basePath, expectedOutput, plannedFiles); - if (audit && audit.violations.length > 0) { - const warnings = audit.violations.filter(v => v.severity === "warning"); - for (const v of warnings) { - logWarning("safety", `file-change: ${v.file} — ${v.reason}`); - } - if (warnings.length > 0) { - ctx.ui.notify( - `Safety: ${warnings.length} unexpected file change(s) outside task plan`, - "warning", - ); - } - } - } - } catch (e) { - debugLog("postUnit", { phase: "safety-file-change", error: String(e) }); - } - } - - // Evidence cross-reference (execute-task only) - // Verification evidence is passed via the complete-task tool call and - // stored in the SUMMARY.md on disk — not available as structured data - // in the DB. The evidence collector tracks actual bash tool calls, so - // we can still detect units that claimed success but ran no commands. - if (safetyConfig.evidence_cross_reference && s.currentUnit.type === "execute-task") { - try { - const actual = getEvidence(); - const bashCalls = actual.filter(e => e.kind === "bash"); - // If the task is marked complete but zero bash commands were run, - // it's suspicious — the LLM may have fabricated results. - if (sMid && sSid && sTid && isDbAvailable()) { - const taskRow = getTask(sMid, sSid, sTid); - if (taskRow?.status === "complete" && taskRow.verify && bashCalls.length === 0) { - logWarning("safety", "task marked complete with verification commands but no bash calls were executed"); - ctx.ui.notify( - `Safety: task ${sTid} has verification commands but no bash calls were recorded`, - "warning", - ); - } - } - } catch (e) { - debugLog("postUnit", { phase: "safety-evidence-xref", error: String(e) }); - } - } - - // Content validation (plan-slice, plan-milestone) - if (safetyConfig.content_validation) { - try { - const artifactPath = resolveArtifactForContent(s.currentUnit.type, s.currentUnit.id, s.basePath); - const contentViolations = validateContent(s.currentUnit.type, artifactPath); - for (const v of contentViolations) { - logWarning("safety", `content: ${v.reason}`); - ctx.ui.notify(`Content validation: ${v.reason}`, "warning"); - } - } catch (e) { - debugLog("postUnit", { phase: "safety-content-validation", error: String(e) }); - } - } - } - } catch (e) { - debugLog("postUnit", { phase: "safety-harness", error: String(e) }); - } - - // Artifact verification - let triggerArtifactVerified = false; - if (!s.currentUnit.type.startsWith("hook/")) { - try { - triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); - if (triggerArtifactVerified) { - invalidateAllCaches(); - } - } catch (e) { - debugLog("postUnit", { phase: "artifact-verify", error: String(e) }); - } - - // If verification failed, attempt to regenerate missing projection files - // from DB data before giving up (e.g. research-slice produces PLAN from engine). - if (!triggerArtifactVerified) { - try { - const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); - if (mid && sid) { - const regenerated = regenerateIfMissing(s.basePath, mid, sid, "PLAN"); - if (regenerated) { - // Re-check after regeneration - triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); - if (triggerArtifactVerified) { - invalidateAllCaches(); - } - } - } - } catch (e) { - debugLog("postUnit", { phase: "regenerate-projection", error: String(e) }); - } - } - - // When artifact verification fails for a unit type that has a known expected - // artifact, return "retry" so the caller re-dispatches with failure context - // instead of blindly re-dispatching the same unit (#1571). - // After MAX_VERIFICATION_RETRIES, escalate to writeBlockerPlaceholder so the - // pipeline can advance instead of looping forever (#2653). - // - // HOWEVER, if the DB is unavailable (db_unavailable), the artifact was never - // written because the completion tool failed at the infra level. Retrying - // can never succeed and produces a costly re-dispatch loop (#2517). - if (!triggerArtifactVerified && !isDbAvailable()) { - // DB infra failure — do NOT retry; the completion tool returned - // db_unavailable so the artifact was never written. Retrying would - // produce an infinite re-dispatch loop (#2517). - debugLog("postUnit", { phase: "artifact-verify-skip-db-unavailable", unitType: s.currentUnit.type, unitId: s.currentUnit.id }); - const dbSkipDiag = diagnoseExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); - ctx.ui.notify( - `Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — DB unavailable, skipping retry.${dbSkipDiag ? ` Expected: ${dbSkipDiag}` : ""}`, - "error", - ); - } else if (!triggerArtifactVerified) { - // #2883/#3595: If the artifact is missing because the tool invocation - // failed (malformed JSON) or was skipped (queued user message), retrying - // will produce the same failure. Pause auto-mode instead of looping. - if (s.lastToolInvocationError) { - const isUserSkip = /queued user message/i.test(s.lastToolInvocationError); - const errMsg = isUserSkip - ? `Tool skipped for ${s.currentUnit.type}: ${s.lastToolInvocationError}. Queued user message interrupted the turn — pausing auto-mode.` - : `Tool invocation failed for ${s.currentUnit.type}: ${s.lastToolInvocationError}. Structured argument generation failed — pausing auto-mode.`; - debugLog("postUnit", { phase: "tool-invocation-error-pause", unitType: s.currentUnit.type, unitId: s.currentUnit.id, error: s.lastToolInvocationError }); - ctx.ui.notify(errMsg, "error"); - s.lastToolInvocationError = null; - await pauseAuto(ctx, pi); - return "dispatched"; - } - - const hasExpectedArtifact = resolveExpectedArtifactPath(s.currentUnit.type, s.currentUnit.id, s.basePath) !== null; - if (hasExpectedArtifact) { - const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`; - const attempt = (s.verificationRetryCount.get(retryKey) ?? 0) + 1; - s.verificationRetryCount.set(retryKey, attempt); - - if (attempt > MAX_VERIFICATION_RETRIES) { - // #4175: For complete-milestone, a blocker placeholder is harmful — - // the stub SUMMARY has no recovery value (milestone is terminal), - // it does not update DB status (so deriveState never advances), - // and it fools stopAuto's presence check into merging a milestone - // that was never legitimately completed. Pause auto-mode with a - // clear single failure signal and preserve the worktree branch. - if (s.currentUnit.type === "complete-milestone") { - debugLog("postUnit", { - phase: "artifact-verify-pause-complete-milestone", - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - attempt, - maxRetries: MAX_VERIFICATION_RETRIES, - }); - s.verificationRetryCount.delete(retryKey); - s.pendingVerificationRetry = null; - ctx.ui.notify( - `Milestone ${s.currentUnit.id} verification failed after ${MAX_VERIFICATION_RETRIES} retries — worktree branch preserved. Re-run /gsd auto once blockers are resolved.`, - "error", - ); - await pauseAuto(ctx, pi); - return "dispatched"; - } - - // Retries exhausted — write a blocker placeholder so the pipeline - // can advance past this stuck unit (#2653). - debugLog("postUnit", { - phase: "artifact-verify-escalate", - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - attempt, - maxRetries: MAX_VERIFICATION_RETRIES, - }); - const reason = `Artifact verification failed after ${MAX_VERIFICATION_RETRIES} retries for ${s.currentUnit.type} "${s.currentUnit.id}".`; - writeBlockerPlaceholder(s.currentUnit.type, s.currentUnit.id, s.basePath, reason); - ctx.ui.notify( - `${s.currentUnit.type} ${s.currentUnit.id} — verification retries exhausted (${MAX_VERIFICATION_RETRIES}), wrote blocker placeholder to advance pipeline`, - "warning", - ); - // Reset retry count and fall through to "continue" so the loop - // re-derives state with the placeholder in place. - s.verificationRetryCount.delete(retryKey); - s.pendingVerificationRetry = null; - // Do NOT return "retry" — fall through to "continue" below. - } else { - s.pendingVerificationRetry = { - unitId: s.currentUnit.id, - failureContext: `Artifact verification failed: expected artifact for ${s.currentUnit.type} "${s.currentUnit.id}" was not found on disk after unit execution (attempt ${attempt}).`, - attempt, - }; - debugLog("postUnit", { phase: "artifact-verify-retry", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt }); - ctx.ui.notify( - `Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — retrying (attempt ${attempt})`, - "warning", - ); - return "retry"; - } - } - } - } else { - // Hook unit completed — no additional processing needed - } - } - - return "continue"; -} - -/** - * Post-verification processing: DB dual-write, post-unit hooks, triage - * capture dispatch, quick-task dispatch. - * - * Sidecar work (hooks, triage, quick-tasks) is enqueued on `s.sidecarQueue` - * for the main loop to drain via `runUnit()`. - * - * Returns: - * - "continue" — proceed to sidecar drain / normal dispatch - * - "step-wizard" — step mode, show wizard instead - * - "stopped" — stopAuto was called - */ -export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"continue" | "step-wizard" | "stopped"> { - const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx; - - if (s.currentUnit) { - try { - const codebasePrefs = loadEffectiveGSDPreferences()?.preferences?.codebase; - const refresh = ensureCodebaseMapFresh( - s.basePath, - codebasePrefs - ? { - excludePatterns: codebasePrefs.exclude_patterns, - maxFiles: codebasePrefs.max_files, - collapseThreshold: codebasePrefs.collapse_threshold, - } - : undefined, - { force: true, ttlMs: 0 }, - ); - if (refresh.status === "generated" || refresh.status === "updated") { - debugLog("postUnit", { - phase: "codebase-refresh", - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - status: refresh.status, - fileCount: refresh.fileCount, - reason: refresh.reason, - }); - } - } catch (e) { - logWarning("engine", `CODEBASE refresh failed: ${(e as Error).message}`); - } - } - - // ── Post-unit hooks ── - if (s.currentUnit && !s.stepMode) { - const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); - if (hookUnit) { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } - persistHookState(s.basePath); - - return enqueueSidecar( - s, ctx, - { kind: "hook", unitType: hookUnit.unitType, unitId: hookUnit.unitId, prompt: hookUnit.prompt, model: hookUnit.model }, - { hookName: hookUnit.hookName }, - ); - } - - // Check if a hook requested a retry of the trigger unit - if (isRetryPending()) { - const trigger = consumeRetryTrigger(); - if (trigger) { - ctx.ui.notify( - `Hook requested retry of ${trigger.unitType} ${trigger.unitId} — resetting task state.`, - "info", - ); - - // ── State reset: undo the completion so deriveState re-derives the unit ── - try { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(trigger.unitId); - - // 1. Reset task status in DB and re-render plan checkboxes - if (mid && sid && tid) { - try { - updateTaskStatus(mid, sid, tid, "pending"); - await renderPlanCheckboxes(s.basePath, mid, sid); - } catch (dbErr) { - // DB unavailable — fail explicitly rather than silently reverting to markdown mutation. - // Use 'gsd recover' to rebuild DB state from disk if needed. - logError("engine", `retry state-reset failed (DB unavailable): ${(dbErr as Error).message}. Run 'gsd recover' to reconcile.`); - } - } - - // 2. Delete SUMMARY.md for the task - if (mid && sid && tid) { - const tasksDir = resolveTasksDir(s.basePath, mid, sid); - if (tasksDir) { - const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY")); - if (existsSync(summaryFile)) { - unlinkSync(summaryFile); - } - } - } - - // 3. Delete the retry_on artifact (e.g. NEEDS-REWORK.md) - if (trigger.retryArtifact) { - const retryArtifactPath = resolveHookArtifactPath(s.basePath, trigger.unitId, trigger.retryArtifact); - if (existsSync(retryArtifactPath)) { - unlinkSync(retryArtifactPath); - } - } - - // 5. Invalidate caches so deriveState reads fresh disk state - invalidateAllCaches(); - } catch (e) { - debugLog("postUnitPostVerification", { phase: "retry-state-reset", error: String(e) }); - } - - // Fall through to normal dispatch — deriveState will re-derive the unit - } - } - } - - // ── Fast-path stop detection (#3487) ── - // Before waiting for triage, check if any PENDING captures contain explicit - // stop/halt language. If so, pause immediately — don't wait for triage. - if (s.currentUnit && s.currentUnit.type !== "triage-captures") { - try { - const pending = loadPendingCaptures(s.basePath); - // Match only when the capture text starts with a stop/halt directive word, - // or the entire text is short and dominated by such a word. This avoids - // false positives on captures like "add a pause button" or "stop the timer - // from re-rendering" — those are feature descriptions, not halt directives. - const STOP_PATTERN = /^(stop|halt|abort|don'?t continue|pause|cease)\b/i; - const stopCapture = pending.find(c => STOP_PATTERN.test(c.text.trim())); - if (stopCapture) { - ctx.ui.notify( - `Stop directive detected in pending capture ${stopCapture.id}: "${stopCapture.text}" — pausing auto-mode.`, - "warning", - ); - debugLog("postUnit", { phase: "fast-stop", captureId: stopCapture.id }); - await pauseAuto(ctx, pi); - return "stopped"; - } - } catch (e) { - debugLog("postUnit", { phase: "fast-stop-error", error: String(e) }); - } - } - - // ── Capture protection: revert executor-silenced captures (#3487) ── - // Non-triage agents can write **Status:** resolved to CAPTURES.md, bypassing - // the triage pipeline. Revert those to pending before the triage check. - if ( - s.currentUnit && - s.currentUnit.type !== "triage-captures" - ) { - try { - const reverted = revertExecutorResolvedCaptures(s.basePath); - if (reverted > 0) { - debugLog("postUnit", { phase: "capture-protection", reverted }); - ctx.ui.notify( - `Reverted ${reverted} capture${reverted === 1 ? "" : "s"} silenced by executor — re-queuing for triage.`, - "warning", - ); - } - } catch (e) { - debugLog("postUnit", { phase: "capture-protection-error", error: String(e) }); - } - } - - // ── Pre-execution checks (after plan-slice completes) ── - if ( - s.currentUnit && - s.currentUnit.type === "plan-slice" - ) { - const currentUnit = s.currentUnit; - let preExecPauseNeeded = false; - await runSafely("postUnitPostVerification", "pre-execution-checks", async () => { - const prefs = loadEffectiveGSDPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - try { - // Check preferences — respect enhanced_verification and enhanced_verification_pre - const enhancedEnabled = prefs?.enhanced_verification !== false; // default true - const preEnabled = prefs?.enhanced_verification_pre !== false; // default true - - if (!enhancedEnabled || !preEnabled) { - debugLog("postUnitPostVerification", { - phase: "pre-execution-checks", - skipped: true, - reason: "disabled by preferences", - }); - return; - } - - // Parse the unit ID to get milestone/slice IDs - const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id); - if (!mid || !sid) { - debugLog("postUnitPostVerification", { - phase: "pre-execution-checks", - skipped: true, - reason: "could not parse milestone/slice from unit ID", - }); - return; - } - - // Get tasks for this slice from DB - const tasks = getSliceTasks(mid, sid); - if (tasks.length === 0) { - debugLog("postUnitPostVerification", { - phase: "pre-execution-checks", - skipped: true, - reason: "no tasks found for slice", - }); - return; - } - - const strictMode = prefs?.enhanced_verification_strict === true; - - // Run pre-execution checks - const result: PreExecutionResult = await runPreExecutionChecks(tasks, s.basePath); - - // Log summary to stderr in existing verification output format - const emoji = result.status === "pass" ? "✅" : result.status === "warn" ? "⚠️" : "❌"; - process.stderr.write( - `gsd-pre-exec: ${emoji} Pre-execution checks ${result.status} for ${mid}/${sid} (${result.durationMs}ms)\n`, - ); - - // Log individual check results - for (const check of result.checks) { - const checkEmoji = check.passed ? "✓" : check.blocking ? "✗" : "⚠"; - process.stderr.write( - `gsd-pre-exec: ${checkEmoji} [${check.category}] ${check.target}: ${check.message}\n`, - ); - } - - // Write evidence JSON to slice artifacts directory - const slicePath = resolveSlicePath(s.basePath, mid, sid); - if (slicePath) { - writePreExecutionEvidence(result, slicePath, mid, sid); - } - - if (uokFlags.gates) { - const failedChecks = result.checks - .filter((check) => !check.passed) - .map((check) => `[${check.category}] ${check.target}: ${check.message}`); - const warnEscalated = result.status === "warn" && strictMode; - const blockingFailure = result.status === "fail" || warnEscalated; - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "pre-execution-checks", - type: "input", - execute: async () => ({ - outcome: blockingFailure ? "fail" : "pass", - failureClass: result.status === "fail" ? "input" : warnEscalated ? "policy" : "none", - rationale: blockingFailure - ? `pre-execution checks ${result.status}${warnEscalated ? " (strict)" : ""}` - : "pre-execution checks passed", - findings: failedChecks.join("\n"), - }), - }); - await gateRunner.run("pre-execution-checks", { - basePath: s.basePath, - traceId: `pre-execution:${currentUnit.id}`, - turnId: currentUnit.id, - milestoneId: mid, - sliceId: sid, - unitType: currentUnit.type, - unitId: currentUnit.id, - }); - } - - // Notify UI - if (result.status === "fail") { - const blockingCount = result.checks.filter(c => !c.passed && c.blocking).length; - ctx.ui.notify( - `Pre-execution checks failed: ${blockingCount} blocking issue${blockingCount === 1 ? "" : "s"} found`, - "error", - ); - preExecPauseNeeded = true; - } else if (result.status === "warn") { - ctx.ui.notify( - `Pre-execution checks passed with warnings`, - "warning", - ); - // Strict mode: treat warnings as blocking - if (prefs?.enhanced_verification_strict === true) { - preExecPauseNeeded = true; - } - } - - debugLog("postUnitPostVerification", { - phase: "pre-execution-checks", - status: result.status, - checkCount: result.checks.length, - durationMs: result.durationMs, - }); - } catch (preExecError) { - // Fail-closed: if runPreExecutionChecks throws, pause auto-mode instead of silently continuing - const errorMessage = preExecError instanceof Error ? preExecError.message : String(preExecError); - debugLog("postUnitPostVerification", { - phase: "pre-execution-checks", - error: errorMessage, - failClosed: true, - }); - logError("engine", `gsd-pre-exec: Pre-execution checks threw an error: ${errorMessage}`); - ctx.ui.notify( - `Pre-execution checks error: ${errorMessage} — pausing for human review`, - "error", - ); - if (uokFlags.gates && s.currentUnit) { - const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "pre-execution-checks", - type: "input", - execute: async () => ({ - outcome: "manual-attention", - failureClass: "manual-attention", - rationale: "pre-execution checks threw before completion", - findings: errorMessage, - }), - }); - await gateRunner.run("pre-execution-checks", { - basePath: s.basePath, - traceId: `pre-execution:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - } - preExecPauseNeeded = true; - } - }); - - // Check for blocking failures after runSafely completes - if (preExecPauseNeeded) { - debugLog("postUnitPostVerification", { phase: "pre-execution-checks", pausing: true, reason: "blocking failures detected" }); - await pauseAuto(ctx, pi); - return "stopped"; - } - } - - // ── Triage check ── - if ( - !s.stepMode && - s.currentUnit && - !s.currentUnit.type.startsWith("hook/") && - s.currentUnit.type !== "triage-captures" && - s.currentUnit.type !== "quick-task" - ) { - try { - if (hasPendingCaptures(s.basePath)) { - const pending = loadPendingCaptures(s.basePath); - if (pending.length > 0) { - const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; - - if (mid && sid) { - let currentPlan = ""; - let roadmapContext = ""; - const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); - if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; - const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP"); - if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; - - const capturesList = pending.map(c => - `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` - ).join("\n"); - - const prompt = loadPrompt("triage-captures", { - pendingCaptures: capturesList, - currentPlan: currentPlan || "(no active slice plan)", - roadmapContext: roadmapContext || "(no active roadmap)", - }); - - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - const triageUnitId = `${mid}/${sid}/triage`; - return enqueueSidecar( - s, ctx, - { kind: "triage", unitType: "triage-captures", unitId: triageUnitId, prompt }, - { pendingCount: pending.length }, - `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, - ); - } - } - } - } catch (e) { - debugLog("postUnit", { phase: "triage-check", error: String(e) }); - } - } - - // ── Quick-task dispatch ── - if ( - !s.stepMode && - s.pendingQuickTasks.length > 0 && - s.currentUnit && - s.currentUnit.type !== "quick-task" - ) { - try { - const capture = s.pendingQuickTasks.shift()!; - const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); - const { markCaptureExecuted } = await import("./captures.js"); - const prompt = buildQuickTaskPrompt(capture); - - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - markCaptureExecuted(s.basePath, capture.id); - - const qtUnitId = `${s.currentMilestoneId}/${capture.id}`; - return enqueueSidecar( - s, ctx, - { kind: "quick-task", unitType: "quick-task", unitId: qtUnitId, prompt, captureId: capture.id }, - { captureId: capture.id }, - `Executing quick-task: ${capture.id} — "${capture.text}"`, - ); - } catch (e) { - debugLog("postUnit", { phase: "quick-task-dispatch", error: String(e) }); - } - } - - // Step mode → show wizard instead of dispatch. - // Without this notify(), /gsd in step mode finishes a unit and silently - // exits the loop, leaving the user with no hint to /clear and /gsd again. - if (s.stepMode) { - try { - const nextState = await deriveState(s.basePath); - ctx.ui.notify(buildStepCompleteMessage(nextState), "info"); - } catch (e) { - debugLog("postUnit", { phase: "step-wizard-notify", error: String(e) }); - ctx.ui.notify(STEP_COMPLETE_FALLBACK_MESSAGE, "info"); - } - return "step-wizard"; - } - - return "continue"; -} diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts deleted file mode 100644 index 51e222d8d..000000000 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ /dev/null @@ -1,2253 +0,0 @@ -/** - * Auto-mode Prompt Builders — construct dispatch prompts for each unit type. - * - * Pure async functions that load templates and inline file content. No module-level - * state, no globals — every dependency is passed as a parameter or imported as a - * utility. - */ - -import { loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection, parseTaskPlanFile } from "./files.js"; -import type { Override, UatType } from "./files.js"; -import { hasVerdict, getUatType } from "./verdict-parser.js"; -import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; -import { - resolveMilestoneFile, resolveSliceFile, resolveSlicePath, - resolveTasksDir, resolveTaskFiles, resolveTaskFile, - relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, - resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile, -} from "./paths.js"; -import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences, resolveAllSkillReferences } from "./preferences.js"; -import { parseRoadmap } from "./parsers-legacy.js"; -import type { GSDState, InlineLevel } from "./types.js"; -import type { GSDPreferences } from "./preferences.js"; -import { getLoadedSkills, type Skill } from "@sf-run/pi-coding-agent"; -import { join, basename } from "node:path"; -import { existsSync } from "node:fs"; -import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js"; -import { getPendingGates, getPendingGatesForTurn } from "./gsd-db.js"; -import { - GATE_REGISTRY, - assertGateCoverage, - getGatesForTurn, - type GateDefinition, -} from "./gate-registry.js"; -import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js"; -import { readPhaseAnchor, formatAnchorForPrompt } from "./phase-anchor.js"; -import { logWarning } from "./workflow-logger.js"; -import { inlineGraphSubgraph } from "./graph-context.js"; - -// ─── Preamble Cap ───────────────────────────────────────────────────────────── - -const MAX_PREAMBLE_CHARS = 30_000; - -function capPreamble(preamble: string): string { - if (preamble.length <= MAX_PREAMBLE_CHARS) return preamble; - return truncateAtSectionBoundary(preamble, MAX_PREAMBLE_CHARS).content; -} - -// ─── Executor Constraints ───────────────────────────────────────────────────── - -/** - * Format executor context constraints for injection into the plan-slice prompt. - * Uses the budget engine to compute task count ranges and inline context budgets - * based on the configured executor model's context window. - */ -function formatExecutorConstraints(): string { - let windowTokens: number; - try { - const prefs = loadEffectiveGSDPreferences(); - windowTokens = resolveExecutorContextWindow(undefined, prefs?.preferences); - } catch (e) { - logWarning("prompt", `resolveExecutorContextWindow failed: ${(e as Error).message}`); - windowTokens = 200_000; // safe default - } - const budgets = computeBudgets(windowTokens); - const { min, max } = budgets.taskCountRange; - const execWindowK = Math.round(windowTokens / 1000); - const perTaskBudgetK = Math.round(budgets.inlineContextBudgetChars / 1000); - return [ - `## Executor Context Constraints`, - ``, - `The agent that executes each task has a **${execWindowK}K token** context window.`, - `- Recommended task count for this slice: **${min}–${max} tasks**`, - `- Each task gets ~${perTaskBudgetK}K chars of inline context (plans, code, decisions)`, - `- Keep individual tasks completable within a single context window — if a task needs more context than fits, split it`, - ].join("\n"); -} - -function buildSourceFilePaths( - base: string, - mid: string, - sid?: string, -): string { - const paths: string[] = []; - - const projectPath = resolveGsdRootFile(base, "PROJECT"); - if (existsSync(projectPath)) { - paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``); - } - - const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS"); - if (existsSync(requirementsPath)) { - paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``); - } - - const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); - if (existsSync(decisionsPath)) { - paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``); - } - - const queuePath = resolveGsdRootFile(base, "QUEUE"); - if (existsSync(queuePath)) { - paths.push(`- **Queue**: \`${relGsdRootFile("QUEUE")}\``); - } - - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - if (contextPath) { - paths.push(`- **Milestone Context**: \`${relMilestoneFile(base, mid, "CONTEXT")}\``); - } - - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapPath) { - paths.push(`- **Roadmap**: \`${relMilestoneFile(base, mid, "ROADMAP")}\``); - } - - if (sid) { - const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); - if (researchPath) { - paths.push(`- **Slice Research**: \`${relSliceFile(base, mid, sid, "RESEARCH")}\``); - } - } else { - const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); - if (researchPath) { - paths.push(`- **Milestone Research**: \`${relMilestoneFile(base, mid, "RESEARCH")}\``); - } - } - - return paths.length > 0 - ? paths.join("\n") - : "- Use `rg --files` and targeted reads to identify the relevant source files before planning."; -} - -// ─── Inline Helpers ─────────────────────────────────────────────────────── - -/** - * Load a file and format it for inlining into a prompt. - * Returns the content wrapped with a source path header, or a fallback - * message if the file doesn't exist. This eliminates tool calls — the LLM - * gets the content directly instead of "Read this file:". - */ -export async function inlineFile( - absPath: string | null, relPath: string, label: string, -): Promise { - const content = absPath ? await loadFile(absPath) : null; - if (!content) { - return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`; - } - return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; -} - -/** - * Load a file for inlining, returning null if it doesn't exist. - * Use when the file is optional and should be omitted entirely if absent. - */ -export async function inlineFileOptional( - absPath: string | null, relPath: string, label: string, -): Promise { - const content = absPath ? await loadFile(absPath) : null; - if (!content) return null; - return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; -} - -/** - * Smart file inlining — for large files, use semantic chunking to include - * only the most relevant portions based on the task context. - * Falls back to full content for small files or when no query is provided. - * - * @param absPath Absolute file path - * @param relPath Relative display path - * @param label Section label - * @param query Task description for relevance scoring (optional) - * @param threshold Character threshold for chunking (default: 3000) - */ -export async function inlineFileSmart( - absPath: string | null, relPath: string, label: string, - query?: string, threshold = 3000, -): Promise { - const content = absPath ? await loadFile(absPath) : null; - if (!content) { - return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`; - } - - // For small files or no query, include full content - if (content.length <= threshold || !query) { - return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`; - } - - // For large files, truncate at section boundary - const truncated = truncateAtSectionBoundary(content, threshold).content; - return `### ${label}\nSource: \`${relPath}\`\n\n${truncated}`; -} - -/** - * Load and inline dependency slice summaries (full content, not just paths). - */ -export async function inlineDependencySummaries( - mid: string, sid: string, base: string, budgetChars?: number, -): Promise { - // DB primary path — get slice depends directly - let depends: string[] | null = null; - try { - const { isDbAvailable, getSlice } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const slice = getSlice(mid, sid); - if (slice) { - if (slice.depends.length === 0) return "- (no dependencies)"; - depends = slice.depends as string[]; - } - // If slice not found in DB, fall through to file-based parsing - } - } catch (err) { - logWarning("prompt", `inlineDependencySummaries DB lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // If DB didn't provide depends, fall back to roadmap parsing - if (!depends) { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapPath) { - const roadmapContent = await loadFile(roadmapPath); - if (roadmapContent) { - const parsed = parseRoadmap(roadmapContent); - const slice = parsed.slices.find(s => s.id === sid); - if (slice && slice.depends.length > 0) { - depends = slice.depends; - } - } - } - if (!depends) { - return "- (no dependencies)"; - } - } - - const sections: string[] = []; - const seen = new Set(); - for (const dep of depends) { - if (seen.has(dep)) continue; - seen.add(dep); - const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY"); - const summaryContent = summaryFile ? await loadFile(summaryFile) : null; - const relPath = relSliceFile(base, mid, dep, "SUMMARY"); - if (summaryContent) { - sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`); - } else { - sections.push(`- \`${relPath}\` _(not found)_`); - } - } - - const result = sections.join("\n\n"); - if (budgetChars !== undefined && result.length > budgetChars) { - return truncateAtSectionBoundary(result, budgetChars).content; - } - return result; -} - -/** - * Load a well-known .gsd/ root file for optional inlining. - * Handles the existsSync check internally. - */ -export async function inlineGsdRootFile( - base: string, filename: string, label: string, -): Promise { - const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS" | "KNOWLEDGE"; - const absPath = resolveGsdRootFile(base, key); - if (!existsSync(absPath)) return null; - return inlineFileOptional(absPath, relGsdRootFile(key), label); -} - -// ─── DB-Aware Inline Helpers ────────────────────────────────────────────── - -/** - * Inline decisions with optional milestone scoping from the DB. - * Falls back to filesystem via inlineGsdRootFile only when DB is unavailable. - * - * Cascade logic (R005): - * 1. Query with { milestoneId, scope } if scope provided - * 2. If empty AND scope was provided, retry with { milestoneId } only (drop scope) - * 3. If still empty, return null (intentional per D020) - */ -export async function inlineDecisionsFromDb( - base: string, milestoneId?: string, scope?: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - try { - const { isDbAvailable } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const { queryDecisions, formatDecisionsForPrompt } = await import("./context-store.js"); - - // First query: try with both milestoneId and scope (if scope provided) - let decisions = queryDecisions({ milestoneId, scope }); - - // Cascade: if empty AND scope was provided, retry without scope - if (decisions.length === 0 && scope) { - decisions = queryDecisions({ milestoneId }); - } - - if (decisions.length > 0) { - // Use compact format for non-full levels to save ~35% tokens - const formatted = inlineLevel !== "full" - ? formatDecisionsCompact(decisions) - : formatDecisionsForPrompt(decisions); - return `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`; - } - // DB available but cascade returned empty — intentional per D020, don't fall back to file - return null; - } - } catch (err) { - logWarning("prompt", `inlineDecisionsFromDb failed: ${err instanceof Error ? err.message : String(err)}`); - } - // DB unavailable — fall back to filesystem - return inlineGsdRootFile(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. - */ -export async function inlineRequirementsFromDb( - base: string, milestoneId?: string, sliceId?: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - try { - const { isDbAvailable } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const { queryRequirements, formatRequirementsForPrompt } = await import("./context-store.js"); - const requirements = queryRequirements({ milestoneId, sliceId }); - if (requirements.length > 0) { - // Use compact format for non-full levels to save ~40% tokens - const formatted = inlineLevel !== "full" - ? formatRequirementsCompact(requirements) - : formatRequirementsForPrompt(requirements); - return `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`; - } - } - } catch (err) { - logWarning("prompt", `inlineRequirementsFromDb failed: ${err instanceof Error ? err.message : String(err)}`); - } - return inlineGsdRootFile(base, "requirements.md", "Requirements"); -} - -/** - * Inline project context from the DB. - * Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty. - */ -export async function inlineProjectFromDb( - base: string, -): Promise { - try { - const { isDbAvailable } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const { queryProject } = await import("./context-store.js"); - const content = queryProject(); - if (content) { - return `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`; - } - } - } catch (err) { - logWarning("prompt", `inlineProjectFromDb failed: ${err instanceof Error ? err.message : String(err)}`); - } - return inlineGsdRootFile(base, "project.md", "Project"); -} - -// ─── Stopwords for keyword extraction ───────────────────────────────────── -const STOPWORDS = new Set(['of', 'the', 'and', 'a', 'for', '+', '-', 'to', 'in', 'on', 'with', 'is', 'as', 'by']); - -// Generic words that don't provide meaningful scope differentiation -const GENERIC_WORDS = new Set([ - 'setup', 'integration', 'implementation', 'testing', 'test', 'tests', - 'config', 'configuration', 'init', 'initial', 'basic', 'core', - 'main', 'primary', 'final', 'complete', 'finish', 'end', - 'start', 'begin', 'first', 'last', 'update', 'updates', - 'fix', 'fixes', 'add', 'adds', 'remove', 'removes', - 'create', 'creates', 'build', 'builds', 'deploy', 'deployment', - 'refactor', 'refactoring', 'cleanup', 'polish', 'review', - // Process/activity words that describe what you're doing, not what domain - 'hardening', 'validation', 'verification', 'optimization', - 'improvement', 'enhancement', 'infrastructure', -]); - -// Pattern to match slice/milestone/task IDs (e.g., S01, M001, T03) -const UNIT_ID_PATTERN = /^[smt]\d+$/i; - -/** - * Derive a scope keyword from slice title and optional description. - * Returns the most specific noun (first non-generic keyword) for decision scoping. - * - * Examples: - * - "Auth Middleware & Protected Route" → "auth" - * - "Database & User Model Setup" → "database" - * - "Integration Testing" → undefined (too generic) - * - "API Rate Limiting" → "api" - * - * @param sliceTitle - The slice title - * @param sliceDescription - Optional roadmap description (demo text) - * @returns A single lowercase keyword or undefined if no meaningful scope - */ -export function deriveSliceScope(sliceTitle: string, sliceDescription?: string): string | undefined { - // Combine title and description for keyword extraction - const combinedText = sliceDescription - ? `${sliceTitle} ${sliceDescription}` - : sliceTitle; - - // Extract all words, lowercase, remove punctuation - const words = combinedText - .split(/[\s&+,;:|/\\()-]+/) - .map(w => w.toLowerCase().replace(/[^a-z0-9]/g, '')) - .filter(w => w.length >= 2); - - // Find the first word that is: - // 1. Not a stopword - // 2. Not a generic word - // 3. Not a unit ID (S01, M001, T03) - // 4. At least 3 characters (meaningful scope) - for (const word of words) { - if (STOPWORDS.has(word)) continue; - if (GENERIC_WORDS.has(word)) continue; - if (UNIT_ID_PATTERN.test(word)) continue; - if (word.length < 3) continue; - return word; - } - - return undefined; -} -/** - * Extract keywords from a slice title for scoped knowledge queries. - * Splits on whitespace, filters stopwords, lowercases. - * Example: 'KNOWLEDGE scoping + roadmap excerpt' → ['knowledge', 'scoping', 'roadmap', 'excerpt'] - */ -function extractKeywords(title: string): string[] { - return title - .split(/\s+/) - .map(w => w.toLowerCase().replace(/[^a-z0-9]/g, '')) - .filter(w => w.length > 0 && !STOPWORDS.has(w)); -} - -/** - * Inline scoped KNOWLEDGE.md content based on keywords from slice title. - * Reads KNOWLEDGE.md, filters to sections matching keywords, formats with header. - * Returns null if no KNOWLEDGE.md exists or no sections match. - */ -export async function inlineKnowledgeScoped( - base: string, - keywords: string[], -): Promise { - const knowledgePath = resolveGsdRootFile(base, "KNOWLEDGE"); - if (!existsSync(knowledgePath)) return null; - - const content = await loadFile(knowledgePath); - if (!content) return null; - - // Import queryKnowledge from context-store - const { queryKnowledge } = await import("./context-store.js"); - const scoped = await queryKnowledge(content, keywords); - - // Return null if no sections matched (empty string from queryKnowledge) - if (!scoped) return null; - - return `### Project Knowledge (scoped)\nSource: \`${relGsdRootFile("KNOWLEDGE")}\`\n\n${scoped.trim()}`; -} - -/** - * Inline a roadmap excerpt for a specific slice. - * Reads full roadmap, extracts minimal excerpt with header + predecessor + target row. - * Returns null if roadmap doesn't exist or slice not found. - */ -export async function inlineRoadmapExcerpt( - base: string, - mid: string, - sid: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) return null; - - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const content = await loadFile(roadmapPath); - if (!content) return null; - - // Import formatRoadmapExcerpt from context-store - const { formatRoadmapExcerpt } = await import("./context-store.js"); - const excerpt = formatRoadmapExcerpt(content, sid, roadmapRel); - - // Return null if slice not found in roadmap - if (!excerpt) return null; - - return `### Milestone Roadmap (excerpt)\nSource: \`${roadmapRel}\`\n\n${excerpt}`; -} - -// ─── Skill Activation & Discovery ───────────────────────────────────────── - -function normalizeSkillReference(ref: string): string { - const normalized = ref.replace(/\\/g, "/").trim(); - const base = basename(normalized).replace(/\.md$/i, ""); - const name = /^SKILL$/i.test(base) - ? basename(normalized.replace(/\/SKILL(?:\.md)?$/i, "")) - : base; - return name.trim().toLowerCase(); -} - -function tokenizeSkillContext(...parts: Array): Set { - const tokens = new Set(); - const addVariants = (raw: string) => { - const value = raw.trim().toLowerCase(); - if (!value || value.length < 2) return; - tokens.add(value); - tokens.add(value.replace(/[-_]+/g, " ")); - tokens.add(value.replace(/\s+/g, "-")); - tokens.add(value.replace(/\s+/g, "")); - }; - - for (const part of parts) { - if (!part) continue; - const text = part.toLowerCase(); - const phraseMatches = text.match(/[a-z0-9][a-z0-9+.#/_-]{1,}/g) ?? []; - for (const match of phraseMatches) { - addVariants(match); - for (const piece of match.split(/[^a-z0-9+.#]+/g)) { - if (piece.length >= 3) addVariants(piece); - } - } - } - - return tokens; -} - -function skillMatchesContext(skill: Skill, contextTokens: Set): boolean { - const haystacks = [ - skill.name.toLowerCase(), - skill.name.toLowerCase().replace(/[-_]+/g, " "), - skill.description.toLowerCase(), - ]; - - return [...contextTokens].some(token => - token.length >= 3 && haystacks.some(haystack => haystack.includes(token)), - ); -} - -function resolvePreferenceSkillNames(refs: string[], base: string): string[] { - if (refs.length === 0) return []; - const prefs: GSDPreferences = { always_use_skills: refs }; - const report = resolveAllSkillReferences(prefs, base); - return refs.map(ref => { - const resolution = report.resolutions.get(ref); - return normalizeSkillReference(resolution?.resolvedPath ?? ref); - }).filter(Boolean); -} - -function ruleMatchesContext(when: string, contextTokens: Set): boolean { - const whenTokens = tokenizeSkillContext(when); - return [...whenTokens].some(token => - contextTokens.has(token) || [...contextTokens].some(ctx => ctx.includes(token) || token.includes(ctx)), - ); -} - -function resolveSkillRuleMatches( - prefs: GSDPreferences | undefined, - contextTokens: Set, - base: string, -): { include: string[]; avoid: string[] } { - if (!prefs?.skill_rules?.length) return { include: [], avoid: [] }; - - const include: string[] = []; - const avoid: string[] = []; - for (const rule of prefs.skill_rules) { - if (!ruleMatchesContext(rule.when, contextTokens)) continue; - include.push(...resolvePreferenceSkillNames([...(rule.use ?? []), ...(rule.prefer ?? [])], base)); - avoid.push(...resolvePreferenceSkillNames(rule.avoid ?? [], base)); - } - return { include, avoid }; -} - -function resolvePreferredSkillNames( - prefs: GSDPreferences | undefined, - visibleSkills: Skill[], - contextTokens: Set, - base: string, -): string[] { - if (!prefs?.prefer_skills?.length) return []; - const preferred = new Set(resolvePreferenceSkillNames(prefs.prefer_skills, base)); - return visibleSkills - .filter(skill => preferred.has(normalizeSkillReference(skill.name)) && skillMatchesContext(skill, contextTokens)) - .map(skill => normalizeSkillReference(skill.name)); -} - -/** Skill names must be lowercase alphanumeric with hyphens — reject anything else - * to prevent prompt injection via crafted directory names. */ -const SAFE_SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/; - -function formatSkillActivationBlock(skillNames: string[]): string { - const safe = skillNames.filter(name => SAFE_SKILL_NAME.test(name)); - if (safe.length === 0) return ""; - // Use explicit parameter syntax so LLMs pass { skill: "..." } instead of { name: "..." }. - // The function-call-like syntax `Skill('name')` led LLMs to infer a positional - // parameter name, causing tool validation failures — see #2224. - const calls = safe.map(name => `Call Skill({ skill: '${name}' })`).join('. '); - return `${calls}.`; -} - -export function buildSkillActivationBlock(params: { - base: string; - milestoneId: string; - milestoneTitle?: string; - sliceId?: string; - sliceTitle?: string; - taskId?: string; - taskTitle?: string; - extraContext?: string[]; - taskPlanContent?: string | null; - preferences?: GSDPreferences; -}): string { - const prefs = params.preferences ?? loadEffectiveGSDPreferences()?.preferences; - const contextTokens = tokenizeSkillContext( - params.milestoneId, - params.milestoneTitle, - params.sliceId, - params.sliceTitle, - params.taskId, - params.taskTitle, - ); - - const visibleSkills = (typeof getLoadedSkills === 'function' ? getLoadedSkills() : []).filter(skill => !skill.disableModelInvocation); - const installedNames = new Set(visibleSkills.map(skill => normalizeSkillReference(skill.name))); - const avoided = new Set(resolvePreferenceSkillNames(prefs?.avoid_skills ?? [], params.base)); - const matched = new Set(); - - for (const name of resolvePreferenceSkillNames(prefs?.always_use_skills ?? [], params.base)) { - matched.add(name); - } - - const ruleMatches = resolveSkillRuleMatches(prefs, contextTokens, params.base); - for (const name of ruleMatches.include) matched.add(name); - for (const name of ruleMatches.avoid) avoided.add(name); - - for (const name of resolvePreferredSkillNames(prefs, visibleSkills, contextTokens, params.base)) { - matched.add(name); - } - - if (params.taskPlanContent) { - try { - const taskPlan = parseTaskPlanFile(params.taskPlanContent); - for (const skillName of taskPlan.frontmatter.skills_used) { - matched.add(normalizeSkillReference(skillName)); - } - } catch (err) { - logWarning("prompt", `parseTaskPlanFile failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - const ordered = [...matched] - .filter(name => installedNames.has(name) && !avoided.has(name)) - .sort(); - return formatSkillActivationBlock(ordered); -} - -/** - * Build the skill discovery template variables for research prompts. - * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution. - */ -export function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } { - const mode = resolveSkillDiscoveryMode(); - - if (mode === "off") { - return { - skillDiscoveryMode: "off", - skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.", - }; - } - - const autoInstall = mode === "auto"; - const instructions = ` - Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI). - For each, check if a professional agent skill already exists: - - First check \`\` in your system prompt — a skill may already be installed. - - For technologies without an installed skill, run: \`npx skills find ""\` - - Only consider skills that are **directly relevant** to core technologies — not tangentially related. - - Evaluate results by install count and relevance to the actual work.${autoInstall - ? ` - - Install relevant skills: \`npx skills add -g -y\` - - Record installed skills in the "Skills Discovered" section of your research output. - - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.` - : ` - - Note promising skills in your research output with their install commands, but do NOT install them. - - The user will decide which to install.` - }`; - - return { - skillDiscoveryMode: mode, - skillDiscoveryInstructions: instructions, - }; -} - -// ─── Text Helpers ────────────────────────────────────────────────────────── - -export function extractMarkdownSection(content: string, heading: string): string | null { - const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); - if (!match) return null; - - const start = match.index + match[0].length; - const rest = content.slice(start); - const nextHeading = rest.match(/^##\s+/m); - const end = nextHeading?.index ?? rest.length; - return rest.slice(0, end).trim(); -} - -export function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function oneLine(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -// ─── Section Builders ────────────────────────────────────────────────────── - -export function buildResumeSection( - continueContent: string | null, - legacyContinueContent: string | null, - continueRelPath: string, - legacyContinueRelPath: string | null, -): string { - const resolvedContent = continueContent ?? legacyContinueContent; - const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath; - - if (!resolvedContent || !resolvedRelPath) { - return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); - } - - const cont = parseContinue(resolvedContent); - const lines = [ - "## Resume State", - `Source: \`${resolvedRelPath}\``, - `- Status: ${cont.frontmatter.status || "in_progress"}`, - ]; - - if (cont.frontmatter.step && cont.frontmatter.totalSteps) { - lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); - } - if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); - if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); - if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); - if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); - - return lines.join("\n"); -} - -export async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise { - if (priorSummaryPaths.length === 0) { - return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n"); - } - - const items = await Promise.all(priorSummaryPaths.map(async (relPath) => { - const absPath = join(base, relPath); - const content = await loadFile(absPath); - if (!content) return `- \`${relPath}\``; - - const summary = parseSummary(content); - const provided = summary.frontmatter.provides.slice(0, 2).join("; "); - const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); - const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); - const keyFiles = summary.frontmatter.key_files.slice(0, 3).join("; "); - const diagnostics = extractMarkdownSection(content, "Diagnostics"); - - const parts = [summary.title || relPath]; - if (summary.oneLiner) parts.push(summary.oneLiner); - if (provided) parts.push(`provides: ${provided}`); - if (decisions) parts.push(`decisions: ${decisions}`); - if (patterns) parts.push(`patterns: ${patterns}`); - if (keyFiles) parts.push(`key_files: ${keyFiles}`); - if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); - - return `- \`${relPath}\` — ${parts.join(" | ")}`; - })); - - return ["## Carry-Forward Context", ...items].join("\n"); -} - -export function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { - if (!content) { - return [ - "## Slice Plan Excerpt", - `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, - ].join("\n"); - } - - const lines = content.split("\n"); - const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim(); - const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim(); - - const verification = extractMarkdownSection(content, "Verification"); - const observability = extractMarkdownSection(content, "Observability / Diagnostics"); - - const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; - if (goalLine) parts.push(goalLine); - if (demoLine) parts.push(demoLine); - if (verification) { - parts.push("", "### Slice Verification", verification.trim()); - } - if (observability) { - parts.push("", "### Slice Observability / Diagnostics", observability.trim()); - } - - return parts.join("\n"); -} - -// ─── Prior Task Summaries ────────────────────────────────────────────────── - -export async function getPriorTaskSummaryPaths( - mid: string, sid: string, currentTid: string, base: string, -): Promise { - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return []; - - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); - const currentNum = parseInt(currentTid.replace(/^T/, ""), 10); - const sRel = relSlicePath(base, mid, sid); - - return summaryFiles - .filter(f => { - const num = parseInt(f.replace(/^T/, ""), 10); - return num < currentNum; - }) - .map(f => `${sRel}/tasks/${f}`); -} - -/** - * Get carry-forward summary paths scoped to a task's derived dependencies. - * - * Instead of all prior tasks (order-based), returns only summaries for task - * IDs in `dependsOn`. Used by reactive-execute to give each subagent only - * the context it actually needs — not sibling tasks from a parallel batch. - * - * Falls back to order-based when dependsOn is empty (root tasks still get - * any available prior summaries for continuity). - */ -export async function getDependencyTaskSummaryPaths( - mid: string, sid: string, currentTid: string, - dependsOn: string[], base: string, -): Promise { - // If no dependencies, fall back to order-based for root tasks - if (dependsOn.length === 0) { - return getPriorTaskSummaryPaths(mid, sid, currentTid, base); - } - - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return []; - - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); - const sRel = relSlicePath(base, mid, sid); - const depSet = new Set(dependsOn.map((d) => d.toUpperCase())); - - return summaryFiles - .filter((f) => { - // Extract task ID from filename: "T02-SUMMARY.md" → "T02" - const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase(); - return depSet.has(tid); - }) - .map((f) => `${sRel}/tasks/${f}`); -} - -// ─── Adaptive Replanning Checks ──────────────────────────────────────────── - -/** - * Check if the most recently completed slice needs reassessment. - * Returns { sliceId } if reassessment is needed, null otherwise. - * - * Skips reassessment when: - * - No roadmap exists yet - * - No slices are completed - * - The last completed slice already has an assessment file - * - All slices are complete (milestone done — no point reassessing) - */ -export async function checkNeedsReassessment( - base: string, mid: string, state: GSDState, -): Promise<{ sliceId: string } | null> { - // DB primary path — fall through to file-based when DB has no data for this milestone - try { - const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const slices = getMilestoneSlices(mid); - if (slices.length > 0) { - const completedSliceIds = slices.filter(s => s.status === "complete").map(s => s.id); - const hasIncomplete = slices.some(s => s.status !== "complete"); - if (completedSliceIds.length === 0 || !hasIncomplete) return null; - const lastCompleted = completedSliceIds[completedSliceIds.length - 1]; - const assessmentFile = resolveSliceFile(base, mid, lastCompleted, "ASSESSMENT"); - const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); - if (hasAssessment) return null; - const summaryFile = resolveSliceFile(base, mid, lastCompleted, "SUMMARY"); - const hasSummary = !!(summaryFile && await loadFile(summaryFile)); - if (!hasSummary) return null; - return { sliceId: lastCompleted }; - } - } - } catch (err) { - logWarning("prompt", `checkNeedsReassessment DB lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // File-based fallback using roadmap checkboxes - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) return null; - const roadmapContent = await loadFile(roadmapPath); - if (!roadmapContent) return null; - const parsed = parseRoadmap(roadmapContent); - const fileCompletedIds = parsed.slices.filter(s => s.done).map(s => s.id); - const fileHasIncomplete = parsed.slices.some(s => !s.done); - if (fileCompletedIds.length === 0 || !fileHasIncomplete) return null; - const lastDone = fileCompletedIds[fileCompletedIds.length - 1]; - const assessFile = resolveSliceFile(base, mid, lastDone, "ASSESSMENT"); - const hasAssess = !!(assessFile && await loadFile(assessFile)); - if (hasAssess) return null; - const summFile = resolveSliceFile(base, mid, lastDone, "SUMMARY"); - const hasSumm = !!(summFile && await loadFile(summFile)); - if (!hasSumm) return null; - return { sliceId: lastDone }; -} - -/** - * Check if the most recently completed slice needs a UAT run. - * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. - * - * Skips when: - * - No roadmap or no completed slices - * - All slices are done (milestone complete path — reassessment handles it) - * - uat_dispatch preference is not enabled - * - No UAT file exists for the slice - * - UAT result file already exists (idempotent — already ran) - */ -export async function checkNeedsRunUat( - base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined, -): Promise<{ sliceId: string; uatType: UatType } | null> { - // DB primary path — fall through to file-based when DB has no data for this milestone - try { - const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const slices = getMilestoneSlices(mid); - if (slices.length > 0) { - const completedSlices = slices.filter(s => s.status === "complete"); - const incompleteSlices = slices.filter(s => s.status !== "complete"); - if (completedSlices.length === 0) return null; - if (incompleteSlices.length === 0) return null; - if (!prefs?.uat_dispatch) return null; - const lastCompleted = completedSlices[completedSlices.length - 1]; - const sid = lastCompleted.id; - const uatFile = resolveSliceFile(base, mid, sid, "UAT"); - if (!uatFile) return null; - const uatContent = await loadFile(uatFile); - if (!uatContent) return null; - // If the UAT file already contains a verdict, UAT has been run — skip - if (hasVerdict(uatContent)) return null; - // Also check the ASSESSMENT file — the run-uat prompt writes the verdict - // there (via gsd_summary_save artifact_type:"ASSESSMENT"), not into the - // UAT spec file. Without this check the unit re-dispatches indefinitely. - const assessmentFile = resolveSliceFile(base, mid, sid, "ASSESSMENT"); - if (assessmentFile) { - const assessmentContent = await loadFile(assessmentFile); - if (assessmentContent && hasVerdict(assessmentContent)) return null; - } - const uatType = getUatType(uatContent); - return { sliceId: sid, uatType }; - } - } - } catch (err) { - logWarning("prompt", `checkNeedsRunUat DB lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // File-based fallback using roadmap checkboxes - if (!prefs?.uat_dispatch) return null; - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) return null; - const roadmapContent = await loadFile(roadmapPath); - if (!roadmapContent) return null; - const parsed = parseRoadmap(roadmapContent); - const completedFileSlices = parsed.slices.filter(s => s.done); - const incompleteFileSlices = parsed.slices.filter(s => !s.done); - if (completedFileSlices.length === 0 || incompleteFileSlices.length === 0) return null; - const lastCompletedFile = completedFileSlices[completedFileSlices.length - 1]; - const uatSid = lastCompletedFile.id; - const uatFileFb = resolveSliceFile(base, mid, uatSid, "UAT"); - if (!uatFileFb) return null; - const uatContentFb = await loadFile(uatFileFb); - if (!uatContentFb) return null; - // If the UAT file already contains a verdict, UAT has been run — skip - if (hasVerdict(uatContentFb)) return null; - // Also check the ASSESSMENT file for the file-based fallback path (same - // reason as the DB path above — verdict lives in ASSESSMENT, not UAT). - const assessmentFileFb = resolveSliceFile(base, mid, uatSid, "ASSESSMENT"); - if (assessmentFileFb) { - const assessmentContentFb = await loadFile(assessmentFileFb); - if (assessmentContentFb && hasVerdict(assessmentContentFb)) return null; - } - const uatTypeFb = getUatType(uatContentFb); - return { sliceId: uatSid, uatType: uatTypeFb }; -} - -// ─── Prompt Builders ────────────────────────────────────────────────────── - -/** - * Build a prompt for the discuss-milestone unit type. - * Loads the guided-discuss-milestone template and inlines the CONTEXT-DRAFT - * as a seed when present. The discussion agent interviews the user, writes - * a full CONTEXT.md, and the phase transitions to pre-planning automatically. - */ -export async function buildDiscussMilestonePrompt(mid: string, midTitle: string, base: string): Promise { - const discussTemplates = inlineTemplate("context", "Context"); - - const basePrompt = loadPrompt("guided-discuss-milestone", { - milestoneId: mid, - milestoneTitle: midTitle, - inlinedTemplates: discussTemplates, - structuredQuestionsAvailable: "false", - commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally.", - fastPathInstruction: "", - }); - - // If a CONTEXT-DRAFT.md exists, append it as seed material - const draftPath = resolveMilestoneFile(base, mid, "CONTEXT-DRAFT"); - const draftContent = draftPath ? await loadFile(draftPath) : null; - - if (draftContent) { - return `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\nThe following draft was captured from a prior multi-milestone discussion. Use it as seed material — the user has already provided this context. Start with a brief reflection on what the draft covers, then probe for any gaps or open questions before writing the full CONTEXT.md.\n\n${draftContent}`; - } - - return basePrompt; -} - -export async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise { - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - - const inlined: string[] = []; - inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineRequirementsFromDb(base, mid); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineDecisionsFromDb(base, mid); - if (decisionsInline) inlined.push(decisionsInline); - const knowledgeInlineRM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInlineRM) inlined.push(knowledgeInlineRM); - inlined.push(inlineTemplate("research", "Research")); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); - return loadPrompt("research-milestone", { - workingDirectory: base, - milestoneId: mid, milestoneTitle: midTitle, - milestonePath: relMilestonePath(base, mid), - contextPath: contextRel, - outputPath: join(base, outputRelPath), - inlinedContext, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - extraContext: [inlinedContext], - }), - ...buildSkillDiscoveryVars(), - }); -} - -export async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string, level?: InlineLevel): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const researchPath = resolveMilestoneFile(base, mid, "RESEARCH"); - const researchRel = relMilestoneFile(base, mid, "RESEARCH"); - - const inlined: string[] = []; - - // Inject phase handoff anchor from research phase (if available) - const researchAnchor = readPhaseAnchor(base, mid, "research-milestone"); - if (researchAnchor) inlined.push(formatAnchorForPrompt(researchAnchor)); - - inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context")); - const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research"); - if (researchInline) inlined.push(researchInline); - const { inlinePriorMilestoneSummary } = await import("./files.js"); - const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base); - if (priorSummaryInline) inlined.push(priorSummaryInline); - if (inlineLevel !== "minimal") { - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineRequirementsFromDb(base, mid, undefined, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); - if (decisionsInline) inlined.push(decisionsInline); - } - const queuePath = resolveGsdRootFile(base, "QUEUE"); - if (existsSync(queuePath)) { - const queueInline = await inlineFileSmart( - queuePath, - relGsdRootFile("QUEUE"), - "Project Queue", - `${mid} ${midTitle}`, - ); - inlined.push(queueInline); - } - const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInlinePM) inlined.push(knowledgeInlinePM); - inlined.push(inlineTemplate("roadmap", "Roadmap")); - if (inlineLevel === "full") { - inlined.push(inlineTemplate("decisions", "Decisions")); - inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); - inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest")); - } else if (inlineLevel === "standard") { - inlined.push(inlineTemplate("decisions", "Decisions")); - inlined.push(inlineTemplate("plan", "Slice Plan")); - inlined.push(inlineTemplate("task-plan", "Task Plan")); - } - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); - const researchOutputPath = join(base, relMilestoneFile(base, mid, "RESEARCH")); - const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS")); - return loadPrompt("plan-milestone", { - workingDirectory: base, - milestoneId: mid, milestoneTitle: midTitle, - milestonePath: relMilestonePath(base, mid), - contextPath: contextRel, - researchPath: researchRel, - researchOutputPath, - outputPath: join(base, outputRelPath), - secretsOutputPath, - inlinedContext, - sourceFilePaths: buildSourceFilePaths(base, mid), - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - extraContext: [inlinedContext], - }), - ...buildSkillDiscoveryVars(), - }); -} - -export async function buildResearchSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH"); - const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH"); - - const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT"); - const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT"); - - const inlined: string[] = []; - - // Use roadmap excerpt instead of full roadmap for context reduction - const roadmapExcerptRS = await inlineRoadmapExcerpt(base, mid, sid); - if (roadmapExcerptRS) { - inlined.push(roadmapExcerptRS); - } else { - // Fall back to full roadmap if excerpt fails - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - } - - const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); - if (contextInline) inlined.push(contextInline); - const sliceCtxInline = await inlineFileOptional(sliceContextPath, sliceContextRel, "Slice Context (from discussion)"); - if (sliceCtxInline) inlined.push(sliceCtxInline); - const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research"); - if (researchInline) inlined.push(researchInline); - - // Derive scope from slice title for decision filtering (R005) - const derivedScope = deriveSliceScope(sTitle); - const decisionsInline = await inlineDecisionsFromDb(base, mid, derivedScope); - if (decisionsInline) inlined.push(decisionsInline); - const requirementsInline = await inlineRequirementsFromDb(base, mid, sid); - if (requirementsInline) inlined.push(requirementsInline); - - // Use scoped knowledge based on slice title keywords - const keywords = extractKeywords(sTitle); - const knowledgeInlineRS = await inlineKnowledgeScoped(base, keywords); - if (knowledgeInlineRS) inlined.push(knowledgeInlineRS); - - // Knowledge graph: subgraph for this slice (graceful — skipped if no graph.json) - const graphBlockRS = await inlineGraphSubgraph(base, `${sid} ${sTitle}`, { budget: 3000 }); - if (graphBlockRS) inlined.push(graphBlockRS); - - inlined.push(inlineTemplate("research", "Research")); - - const depContent = await inlineDependencySummaries(mid, sid, base); - const activeOverrides = await loadActiveOverrides(base); - const overridesInline = formatOverridesSection(activeOverrides); - if (overridesInline) inlined.unshift(overridesInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH"); - return loadPrompt("research-slice", { - workingDirectory: base, - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - roadmapPath: roadmapRel, - contextPath: contextRel, - milestoneResearchPath: milestoneResearchRel, - outputPath: join(base, outputRelPath), - inlinedContext, - dependencySummaries: depContent, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - sliceId: sid, - sliceTitle: sTitle, - extraContext: [inlinedContext, depContent], - }), - ...buildSkillDiscoveryVars(), - }); -} - -export async function buildPlanSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH"); - const researchRel = relSliceFile(base, mid, sid, "RESEARCH"); - const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT"); - const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT"); - - const inlined: string[] = []; - - // Inject phase handoff anchor from research phase (if available) - const researchSliceAnchor = readPhaseAnchor(base, mid, "research-slice"); - if (researchSliceAnchor) inlined.push(formatAnchorForPrompt(researchSliceAnchor)); - - // Use roadmap excerpt instead of full roadmap for context reduction - const roadmapExcerptPS = await inlineRoadmapExcerpt(base, mid, sid); - if (roadmapExcerptPS) { - inlined.push(roadmapExcerptPS); - } else { - // Fall back to full roadmap if excerpt fails - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - } - - const sliceCtxInline = await inlineFileOptional(sliceContextPath, sliceContextRel, "Slice Context (from discussion)"); - if (sliceCtxInline) inlined.push(sliceCtxInline); - const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research"); - if (researchInline) inlined.push(researchInline); - if (inlineLevel !== "minimal") { - // Derive scope from slice title for decision filtering (R005) - const derivedScopePS = deriveSliceScope(sTitle); - const decisionsInline = await inlineDecisionsFromDb(base, mid, derivedScopePS, inlineLevel); - if (decisionsInline) inlined.push(decisionsInline); - const requirementsInline = await inlineRequirementsFromDb(base, mid, sid, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - } - - // Use scoped knowledge based on slice title keywords - const keywordsPS = extractKeywords(sTitle); - const knowledgeInlinePS = await inlineKnowledgeScoped(base, keywordsPS); - if (knowledgeInlinePS) inlined.push(knowledgeInlinePS); - - // Knowledge graph: subgraph for this slice (graceful — skipped if no graph.json) - const graphBlockPS = await inlineGraphSubgraph(base, `${sid} ${sTitle}`, { budget: 3000 }); - if (graphBlockPS) inlined.push(graphBlockPS); - - inlined.push(inlineTemplate("plan", "Slice Plan")); - if (inlineLevel === "full") { - inlined.push(inlineTemplate("task-plan", "Task Plan")); - } - - const depContent = await inlineDependencySummaries(mid, sid, base); - const planActiveOverrides = await loadActiveOverrides(base); - const planOverridesInline = formatOverridesSection(planActiveOverrides); - if (planOverridesInline) inlined.unshift(planOverridesInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - // Build executor context constraints from the budget engine - const executorContextConstraints = formatExecutorConstraints(); - - const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); - const commitInstruction = "Do not commit — .gsd/ planning docs are managed externally and not tracked in git."; - return loadPrompt("plan-slice", { - workingDirectory: base, - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - roadmapPath: roadmapRel, - researchPath: researchRel, - outputPath: join(base, outputRelPath), - inlinedContext, - dependencySummaries: depContent, - sourceFilePaths: buildSourceFilePaths(base, mid, sid), - executorContextConstraints, - commitInstruction, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - sliceId: sid, - sliceTitle: sTitle, - extraContext: [inlinedContext, depContent], - }), - }); -} - -/** Options for customizing execute-task prompt construction. */ -export interface ExecuteTaskPromptOptions { - level?: InlineLevel; - /** Override carry-forward paths (dependency-based instead of order-based). */ - carryForwardPaths?: string[]; -} - -export async function buildExecuteTaskPrompt( - mid: string, sid: string, sTitle: string, - tid: string, tTitle: string, base: string, - level?: InlineLevel | ExecuteTaskPromptOptions, -): Promise { - const opts: ExecuteTaskPromptOptions = typeof level === "object" && level !== null && !Array.isArray(level) - ? level - : { level: level as InlineLevel | undefined }; - const inlineLevel = opts.level ?? resolveInlineLevel(); - - // Inject phase handoff anchor from planning phase (if available) - const planAnchor = readPhaseAnchor(base, mid, "plan-slice"); - - const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base); - const priorLines = priorSummaries.length > 0 - ? priorSummaries.map(p => `- \`${p}\``).join("\n") - : "- (no prior tasks)"; - - const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN"); - const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; - const taskPlanRelPath = relSlicePath(base, mid, sid) + `/tasks/${tid}-PLAN.md`; - const taskPlanInline = taskPlanContent - ? [ - "## Inlined Task Plan (authoritative local execution contract)", - `Source: \`${taskPlanRelPath}\``, - "", - taskPlanContent.trim(), - ].join("\n") - : [ - "## Inlined Task Plan (authoritative local execution contract)", - `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, - ].join("\n"); - - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; - const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN")); - - // Check for continue file (new naming or legacy) - const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE"); - const legacyContinueDir = resolveSlicePath(base, mid, sid); - const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null; - const continueContent = continueFile ? await loadFile(continueFile) : null; - const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null; - const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE"); - const resumeSection = buildResumeSection( - continueContent, - legacyContinueContent, - continueRelPath, - legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, - ); - - // For minimal inline level, only carry forward the most recent prior summary - const effectivePriorSummaries = inlineLevel === "minimal" && priorSummaries.length > 1 - ? priorSummaries.slice(-1) - : priorSummaries; - const carryForwardSection = await buildCarryForwardSection(effectivePriorSummaries, base); - - // Inline project knowledge if available (smart-chunked for relevance) - const knowledgeAbsPath = resolveGsdRootFile(base, "KNOWLEDGE"); - const knowledgeInlineET = existsSync(knowledgeAbsPath) - ? await inlineFileSmart( - knowledgeAbsPath, - relGsdRootFile("KNOWLEDGE"), - "Project Knowledge", - `${tTitle} ${sTitle}`, // use task + slice title as relevance query - ) - : null; - // Only include if it has content (not a "not found" result) - const knowledgeContent = knowledgeInlineET && !knowledgeInlineET.includes("not found") ? knowledgeInlineET : null; - - // Knowledge graph: tight subgraph for this task (graceful — skipped if no graph.json) - const graphBlockET = await inlineGraphSubgraph(base, `${tid} ${tTitle}`, { budget: 2000 }); - - const inlinedTemplates = inlineLevel === "minimal" - ? inlineTemplate("task-summary", "Task Summary") - : [ - inlineTemplate("task-summary", "Task Summary"), - inlineTemplate("decisions", "Decisions"), - ...(knowledgeContent ? [knowledgeContent] : []), - ...(graphBlockET ? [graphBlockET] : []), - ].join("\n\n---\n\n"); - - const taskSummaryPath = join(base, `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`); - - const activeOverrides = await loadActiveOverrides(base); - const overridesSection = formatOverridesSection(activeOverrides); - - // Compute verification budget for the executor's context window (issue #707) - const prefs = loadEffectiveGSDPreferences(); - const contextWindow = resolveExecutorContextWindow(undefined, prefs?.preferences); - const budgets = computeBudgets(contextWindow); - const verificationBudget = `~${Math.round(budgets.verificationBudgetChars / 1000)}K chars`; - - // Truncate carry-forward section when it exceeds 40% of inline context budget. - const carryForwardBudget = Math.floor(budgets.inlineContextBudgetChars * 0.4); - let finalCarryForward = carryForwardSection; - if (carryForwardSection.length > carryForwardBudget) { - finalCarryForward = truncateAtSectionBoundary(carryForwardSection, carryForwardBudget).content; - } - - // Inline RUNTIME.md if present - const runtimePath = resolveRuntimeFile(base); - const runtimeContent = existsSync(runtimePath) ? await loadFile(runtimePath) : null; - const runtimeContext = runtimeContent - ? `### Runtime Context\nSource: \`.gsd/RUNTIME.md\`\n\n${runtimeContent.trim()}` - : ""; - - const phaseAnchorSection = planAnchor ? formatAnchorForPrompt(planAnchor) : ""; - - // Task-scoped gates owned by execute-task (Q5/Q6/Q7). Pull only the - // gates that plan-slice actually seeded for this task — tasks with no - // external dependencies legitimately skip Q5, tasks with no runtime - // load dimension skip Q6, etc. - const etPending = getPendingGatesForTurn(mid, sid, "execute-task", tid); - assertGateCoverage(etPending, "execute-task", { requireAll: false }); - const gatesToClose = renderGatesToCloseBlock( - getGatesForTurn("execute-task"), - { pending: new Set(etPending.map((g) => g.gate_id)), allowOmit: true }, - ); - - return loadPrompt("execute-task", { - overridesSection, - runtimeContext, - phaseAnchorSection, - workingDirectory: base, - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, - planPath: join(base, relSliceFile(base, mid, sid, "PLAN")), - slicePath: relSlicePath(base, mid, sid), - taskPlanPath: taskPlanRelPath, - taskPlanInline, - slicePlanExcerpt, - carryForwardSection: finalCarryForward, - resumeSection, - priorTaskLines: priorLines, - taskSummaryPath, - inlinedTemplates, - verificationBudget, - gatesToClose, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - sliceId: sid, - sliceTitle: sTitle, - taskId: tid, - taskTitle: tTitle, - taskPlanContent, - extraContext: [taskPlanInline, slicePlanExcerpt, finalCarryForward, resumeSection], - }), - }); -} - -export async function buildCompleteSlicePrompt( - mid: string, _midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); - const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT"); - const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - const sliceCtxInline = await inlineFileOptional(sliceContextPath, sliceContextRel, "Slice Context (from discussion)"); - if (sliceCtxInline) inlined.push(sliceCtxInline); - inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan")); - if (inlineLevel !== "minimal") { - const requirementsInline = await inlineRequirementsFromDb(base, mid, sid, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - } - const knowledgeInlineCS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInlineCS) inlined.push(knowledgeInlineCS); - - // Inline all task summaries for this slice - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); - for (const file of summaryFiles) { - const absPath = join(tDir, file); - const content = await loadFile(absPath); - const sRel = relSlicePath(base, mid, sid); - const relPath = `${sRel}/tasks/${file}`; - if (content) { - inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`); - } - } - } - inlined.push(inlineTemplate("slice-summary", "Slice Summary")); - if (inlineLevel !== "minimal") { - inlined.push(inlineTemplate("uat", "UAT")); - } - const completeActiveOverrides = await loadActiveOverrides(base); - const completeOverridesInline = formatOverridesSection(completeActiveOverrides); - if (completeOverridesInline) inlined.unshift(completeOverridesInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const sliceRel = relSlicePath(base, mid, sid); - const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`); - const sliceUatPath = join(base, `${sliceRel}/${sid}-UAT.md`); - - // Gates owned by complete-slice (e.g. Q8). Pull from the DB so the - // prompt only prompts for gates the plan actually seeded. The tool - // handler closes each gate based on the SUMMARY.md section content - // after the assistant calls gsd_complete_slice. - const csPending = getPendingGatesForTurn(mid, sid, "complete-slice"); - // coverage check: every pending row must be owned by complete-slice. - // requireAll:false because a slice may have already closed some gates. - assertGateCoverage(csPending, "complete-slice", { requireAll: false }); - const gatesToClose = renderGatesToCloseBlock( - getGatesForTurn("complete-slice"), - { pending: new Set(csPending.map((g) => g.gate_id)), allowOmit: true }, - ); - - return loadPrompt("complete-slice", { - workingDirectory: base, - milestoneId: mid, sliceId: sid, sliceTitle: sTitle, - slicePath: sliceRel, - roadmapPath: join(base, roadmapRel), - inlinedContext, - sliceSummaryPath, - sliceUatPath, - gatesToClose, - }); -} - -export async function buildCompleteMilestonePrompt( - mid: string, midTitle: string, base: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - - // Inline all slice summaries (deduplicated by slice ID) - let sliceIds: string[] = []; - try { - const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); - if (isDbAvailable()) { - sliceIds = getMilestoneSlices(mid) - .filter(s => s.status !== "skipped") - .map(s => s.id); - } - } catch (err) { - logWarning("prompt", `buildCompleteMilestonePrompt DB lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - // File-based fallback: parse roadmap for slice IDs when DB has no data - if (sliceIds.length === 0 && roadmapPath) { - const roadmapContent = await loadFile(roadmapPath); - if (roadmapContent) { - sliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id); - } - } - const seenSlices = new Set(); - for (const sid of sliceIds) { - if (seenSlices.has(sid)) continue; - seenSlices.add(sid); - const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, sid, "SUMMARY"); - inlined.push(await inlineFile(summaryPath, summaryRel, `${sid} Summary`)); - } - - // Inline root SF files (skip for minimal — completion can read these if needed) - if (inlineLevel !== "minimal") { - const requirementsInline = await inlineRequirementsFromDb(base, mid, undefined, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); - if (decisionsInline) inlined.push(decisionsInline); - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - } - const knowledgeInlineCM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInlineCM) inlined.push(knowledgeInlineCM); - // 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(contextPath, contextRel, "Milestone Context"); - if (contextInline) inlined.push(contextInline); - inlined.push(inlineTemplate("milestone-summary", "Milestone Summary")); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`); - - return loadPrompt("complete-milestone", { - workingDirectory: base, - milestoneId: mid, - milestoneTitle: midTitle, - roadmapPath: roadmapRel, - inlinedContext, - milestoneSummaryPath, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - extraContext: [inlinedContext], - }), - }); -} - -export async function buildValidateMilestonePrompt( - mid: string, midTitle: string, base: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - - // Inline verification classes from planning (if available in DB) - try { - const { isDbAvailable, getMilestone } = await import("./gsd-db.js"); - if (isDbAvailable()) { - const milestone = getMilestone(mid); - if (milestone) { - const classes: string[] = []; - if (milestone.verification_contract) classes.push(`- **Contract:** ${milestone.verification_contract}`); - if (milestone.verification_integration) classes.push(`- **Integration:** ${milestone.verification_integration}`); - if (milestone.verification_operational) classes.push(`- **Operational:** ${milestone.verification_operational}`); - if (milestone.verification_uat) classes.push(`- **UAT:** ${milestone.verification_uat}`); - if (classes.length > 0) { - inlined.push(`### Verification Classes (from planning)\n\nThese verification tiers were defined during milestone planning. Each non-empty class must be checked for evidence during validation.\n\n${classes.join("\n")}`); - } - } - } - } catch (err) { - logWarning("prompt", `buildValidateMilestonePrompt verification classes lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // Inline all slice summaries and assessment results - let valSliceIds: string[] = []; - try { - const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); - if (isDbAvailable()) { - valSliceIds = getMilestoneSlices(mid) - .filter(s => s.status !== "skipped") - .map(s => s.id); - } - } catch (err) { - logWarning("prompt", `buildValidateMilestonePrompt slice IDs lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - // File-based fallback: parse roadmap for slice IDs when DB has no data - if (valSliceIds.length === 0 && roadmapPath) { - const roadmapContent = await loadFile(roadmapPath); - if (roadmapContent) { - valSliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id); - } - } - const seenValSlices = new Set(); - for (const sid of valSliceIds) { - if (seenValSlices.has(sid)) continue; - seenValSlices.add(sid); - const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, sid, "SUMMARY"); - inlined.push(await inlineFile(summaryPath, summaryRel, `${sid} Summary`)); - - const assessmentPath = resolveSliceFile(base, mid, sid, "ASSESSMENT"); - const assessmentRel = relSliceFile(base, mid, sid, "ASSESSMENT"); - const assessmentInline = await inlineFileOptional(assessmentPath, assessmentRel, `${sid} Assessment`); - if (assessmentInline) inlined.push(assessmentInline); - } - - // Aggregate unresolved follow-ups and known limitations across slices - const outstandingItems: string[] = []; - for (const sid of valSliceIds) { - const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY"); - if (!summaryPath) continue; - const content = await loadFile(summaryPath); - if (!content) continue; - const summary = parseSummary(content); - if (summary.followUps) outstandingItems.push(`- **${sid} Follow-ups:** ${summary.followUps.trim()}`); - if (summary.knownLimitations) outstandingItems.push(`- **${sid} Known Limitations:** ${summary.knownLimitations.trim()}`); - } - if (outstandingItems.length > 0) { - inlined.push(`### Outstanding Items (aggregated from slice summaries)\n\nThese follow-ups and known limitations were documented during slice completion but have not been resolved.\n\n${outstandingItems.join('\n')}`); - } - - // Inline existing VALIDATION file if this is a re-validation round - const validationPath = resolveMilestoneFile(base, mid, "VALIDATION"); - const validationRel = relMilestoneFile(base, mid, "VALIDATION"); - const validationContent = validationPath ? await loadFile(validationPath) : null; - let remediationRound = 0; - if (validationContent) { - const roundMatch = validationContent.match(/remediation_round:\s*(\d+)/); - remediationRound = roundMatch ? parseInt(roundMatch[1], 10) + 1 : 1; - inlined.push(`### Previous Validation (re-validation round ${remediationRound})\nSource: \`${validationRel}\`\n\n${validationContent.trim()}`); - } - - // Inline root SF files - if (inlineLevel !== "minimal") { - const requirementsInline = await inlineRequirementsFromDb(base, mid, undefined, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); - if (decisionsInline) inlined.push(decisionsInline); - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - } - const knowledgeInline = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInline) inlined.push(knowledgeInline); - // Inline milestone context file - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context"); - if (contextInline) inlined.push(contextInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`); - const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`; - - // Every milestone validation turn owns MV01–MV04 unconditionally: the - // registry is the source of truth for which gates the validator must - // address, and the block below is what the template renders so the - // assistant can never accidentally skip one. - const mvGates = getGatesForTurn("validate-milestone"); - const gatesToEvaluate = renderGatesToCloseBlock(mvGates, { - pending: new Set(mvGates.map((g) => g.id)), - allowOmit: false, - }); - - return loadPrompt("validate-milestone", { - workingDirectory: base, - milestoneId: mid, - milestoneTitle: midTitle, - roadmapPath: roadmapOutputPath, - inlinedContext, - validationPath: validationOutputPath, - remediationRound: String(remediationRound), - gatesToEvaluate, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - extraContext: [inlinedContext], - }), - }); -} - -export async function buildReplanSlicePrompt( - mid: string, midTitle: string, sid: string, sTitle: string, base: string, -): Promise { - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); - const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT"); - const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")); - const sliceCtxInline = await inlineFileOptional(sliceContextPath, sliceContextRel, "Slice Context (from discussion)"); - if (sliceCtxInline) inlined.push(sliceCtxInline); - inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan")); - - // Find the blocker task summary — the completed task with blocker_discovered: true - let blockerTaskId = ""; - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort(); - for (const file of summaryFiles) { - const absPath = join(tDir, file); - const content = await loadFile(absPath); - if (!content) continue; - const summary = parseSummary(content); - const sRel = relSlicePath(base, mid, sid); - const relPath = `${sRel}/tasks/${file}`; - if (summary.frontmatter.blocker_discovered) { - blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, ""); - inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`); - } - } - } - - // Inline decisions - const decisionsInline = await inlineDecisionsFromDb(base, mid); - if (decisionsInline) inlined.push(decisionsInline); - const replanActiveOverrides = await loadActiveOverrides(base); - const replanOverridesInline = formatOverridesSection(replanActiveOverrides); - if (replanOverridesInline) inlined.unshift(replanOverridesInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`); - - // Build capture context for replan prompt (captures that triggered this replan) - let captureContext = "(none)"; - try { - const { loadReplanCaptures } = await import("./triage-resolution.js"); - const replanCaptures = loadReplanCaptures(base); - if (replanCaptures.length > 0) { - captureContext = replanCaptures.map(c => - `- **${c.id}**: "${c.text}" — ${c.rationale ?? "no rationale"}` - ).join("\n"); - } - } catch (err) { - logWarning("prompt", `loadReplanCaptures failed: ${err instanceof Error ? err.message : String(err)}`); - } - - return loadPrompt("replan-slice", { - workingDirectory: base, - milestoneId: mid, - sliceId: sid, - sliceTitle: sTitle, - slicePath: relSlicePath(base, mid, sid), - planPath: join(base, slicePlanRel), - blockerTaskId, - inlinedContext, - replanPath, - captureContext, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - sliceId: sid, - sliceTitle: sTitle, - extraContext: [inlinedContext, captureContext], - }), - }); -} - -export async function buildRunUatPrompt( - mid: string, sliceId: string, uatPath: string, uatContent: string, base: string, -): Promise { - const inlined: string[] = []; - inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`)); - - const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY"); - if (summaryPath) { - const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`); - if (summaryInline) inlined.push(summaryInline); - } - - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "ASSESSMENT")); - const uatType = getUatType(uatContent); - - return loadPrompt("run-uat", { - workingDirectory: base, - milestoneId: mid, - sliceId, - uatPath, - uatResultPath, - uatType, - inlinedContext, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - sliceId, - extraContext: [inlinedContext], - }), - }); -} - -export async function buildReassessRoadmapPrompt( - mid: string, midTitle: string, completedSliceId: string, base: string, level?: InlineLevel, -): Promise { - const inlineLevel = level ?? resolveInlineLevel(); - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY"); - const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY"); - const sliceContextPath = resolveSliceFile(base, mid, completedSliceId, "CONTEXT"); - const sliceContextRel = relSliceFile(base, mid, completedSliceId, "CONTEXT"); - - const inlined: string[] = []; - inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap")); - const sliceCtxInline = await inlineFileOptional(sliceContextPath, sliceContextRel, "Slice Context (from discussion)"); - if (sliceCtxInline) inlined.push(sliceCtxInline); - inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`)); - if (inlineLevel !== "minimal") { - const projectInline = await inlineProjectFromDb(base); - if (projectInline) inlined.push(projectInline); - const requirementsInline = await inlineRequirementsFromDb(base, mid, undefined, inlineLevel); - if (requirementsInline) inlined.push(requirementsInline); - const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel); - if (decisionsInline) inlined.push(decisionsInline); - } - const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); - if (knowledgeInlineRA) inlined.push(knowledgeInlineRA); - - const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); - - const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT")); - - // Build deferred captures context for reassess prompt - let deferredCaptures = "(none)"; - try { - const { loadDeferredCaptures } = await import("./triage-resolution.js"); - const deferred = loadDeferredCaptures(base); - if (deferred.length > 0) { - deferredCaptures = deferred.map(c => - `- **${c.id}**: "${c.text}" — ${c.rationale ?? "deferred during triage"}` - ).join("\n"); - } - } catch (err) { - logWarning("prompt", `loadDeferredCaptures failed: ${err instanceof Error ? err.message : String(err)}`); - } - - const reassessCommitInstruction = "Do not commit — .gsd/ planning docs are managed externally and not tracked in git."; - - return loadPrompt("reassess-roadmap", { - workingDirectory: base, - milestoneId: mid, - milestoneTitle: midTitle, - completedSliceId, - roadmapPath: roadmapRel, - assessmentPath, - inlinedContext, - deferredCaptures, - commitInstruction: reassessCommitInstruction, - skillActivation: buildSkillActivationBlock({ - base, - milestoneId: mid, - milestoneTitle: midTitle, - extraContext: [inlinedContext, deferredCaptures], - }), - }); -} - -// ─── Reactive Execute Prompt ────────────────────────────────────────────── - -export async function buildReactiveExecutePrompt( - mid: string, midTitle: string, sid: string, sTitle: string, - readyTaskIds: string[], base: string, - subagentModel?: string, -): Promise { - const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js"); - - // Build graph for context - const taskIO = await loadSliceTaskIO(base, mid, sid); - const graph = deriveTaskGraph(taskIO); - const metrics = graphMetrics(graph); - - // Build graph context section - const graphLines: string[] = []; - for (const node of graph) { - const status = node.done ? "✅ done" : readyTaskIds.includes(node.id) ? "🟢 ready" : "⏳ waiting"; - const deps = node.dependsOn.length > 0 ? ` (depends on: ${node.dependsOn.join(", ")})` : ""; - graphLines.push(`- **${node.id}: ${node.title}** — ${status}${deps}`); - if (node.outputFiles.length > 0) { - graphLines.push(` - Outputs: ${node.outputFiles.map(f => `\`${f}\``).join(", ")}`); - } - } - const graphContext = [ - `Tasks: ${metrics.taskCount}, Edges: ${metrics.edgeCount}, Ready: ${metrics.readySetSize}`, - "", - ...graphLines, - ].join("\n"); - - // Build individual subagent prompts for each ready task - const subagentSections: string[] = []; - const readyTaskListLines: string[] = []; - - for (const tid of readyTaskIds) { - const node = graph.find((n) => n.id === tid); - const tTitle = node?.title ?? tid; - readyTaskListLines.push(`- **${tid}: ${tTitle}**`); - - // Build dependency-scoped carry-forward paths for this task - const depPaths = await getDependencyTaskSummaryPaths( - mid, sid, tid, node?.dependsOn ?? [], base, - ); - - // Build a full execute-task prompt with dependency-based carry-forward - const taskPrompt = await buildExecuteTaskPrompt( - mid, sid, sTitle, tid, tTitle, base, - { carryForwardPaths: depPaths }, - ); - - const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; - subagentSections.push([ - `### ${tid}: ${tTitle}`, - "", - `Use this as the prompt for a \`subagent\` call${modelSuffix}:`, - "", - "```", - taskPrompt, - "```", - ].join("\n")); - } - - const inlinedTemplates = inlineTemplate("task-summary", "Task Summary"); - - return loadPrompt("reactive-execute", { - workingDirectory: base, - milestoneId: mid, - milestoneTitle: midTitle, - sliceId: sid, - sliceTitle: sTitle, - graphContext, - readyTaskCount: String(readyTaskIds.length), - readyTaskList: readyTaskListLines.join("\n"), - subagentPrompts: subagentSections.join("\n\n---\n\n"), - inlinedTemplates, - }); -} - -// ─── Gate Evaluation ────────────────────────────────────────────────────── -// -// Gate definitions (question, guidance, owner turn) now live in -// gate-registry.ts so that prompt builders, dispatch rules, state -// derivation, and tool handlers all consult the same source of truth. -// See gate-registry.ts for the full ownership map. - -/** - * Render a "Gates to Close" block for turns like `complete-slice` and - * `validate-milestone` that own gates which are closed as a side-effect - * of writing artifact sections (not via a dedicated gate-evaluate - * subagent loop). - * - * Returns a plain-text block or an empty string if there are no gates to - * close, so callers can drop it straight into a template variable. - */ -function renderGatesToCloseBlock( - gates: ReadonlyArray, - opts: { pending: ReadonlySet; allowOmit: boolean }, -): string { - const applicable = gates.filter((g) => opts.pending.has(g.id)); - if (applicable.length === 0) return ""; - - const lines: string[] = []; - lines.push("## Gates to Close"); - lines.push(""); - lines.push( - "These quality gates are still pending for this unit. You MUST address every one before calling the closing tool — the handler closes the DB row based on whether the corresponding artifact section is present.", - ); - lines.push(""); - for (const def of applicable) { - lines.push(`### ${def.id} — ${def.promptSection}`); - lines.push(""); - lines.push(`**Question:** ${def.question}`); - lines.push(""); - lines.push(def.guidance); - if (opts.allowOmit) { - lines.push(""); - lines.push( - `If this gate genuinely does not apply to this unit, leave the **${def.promptSection}** section empty and the handler will record it as \`omitted\`. Otherwise, fill the section with concrete evidence.`, - ); - } - lines.push(""); - } - return lines.join("\n").trimEnd(); -} - -export async function buildParallelResearchSlicesPrompt( - mid: string, - midTitle: string, - slices: Array<{ id: string; title: string }>, - basePath: string, - subagentModel?: string, -): Promise { - // Build individual research-slice prompts for each slice - const subagentSections: string[] = []; - const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; - for (const slice of slices) { - const slicePrompt = await buildResearchSlicePrompt(mid, midTitle, slice.id, slice.title, basePath); - subagentSections.push([ - `### ${slice.id}: ${slice.title}`, - "", - `Use this as the prompt for a \`subagent\` call${modelSuffix} (agent: \`gsd-executor\` or the default agent):`, - "", - "```", - slicePrompt, - "```", - ].join("\n")); - } - - return loadPrompt("parallel-research-slices", { - mid, - midTitle, - sliceCount: String(slices.length), - sliceList: slices.map((s) => `- **${s.id}**: ${s.title}`).join("\n"), - subagentPrompts: subagentSections.join("\n\n---\n\n"), - }); -} - -export async function buildGateEvaluatePrompt( - mid: string, midTitle: string, sid: string, sTitle: string, - base: string, - subagentModel?: string, -): Promise { - // Pull only the gates this turn actually owns (Q3/Q4). Filter via the - // registry so that scope:"slice" gates owned by other turns (Q8) can't - // leak into this prompt and can't block dispatch via silent skip. - const pending = getPendingGatesForTurn(mid, sid, "gate-evaluate"); - - // Fails loudly if the pending list contains a gate id the registry - // doesn't own for this turn. Missing owned gates is allowed here — - // `gate-evaluate` is dispatched whenever *any* of its owned gates are - // pending, not only when all of them are. - assertGateCoverage(pending, "gate-evaluate", { requireAll: false }); - - // Load the slice plan for context - const planFile = resolveSliceFile(base, mid, sid, "PLAN"); - const planContent = planFile ? (await loadFile(planFile)) ?? "(plan file empty)" : "(plan file not found)"; - - // Build per-gate subagent prompts from the pending rows. Because the - // registry has already validated every row, `getGateDefinition` cannot - // return undefined here. - const pendingIds = new Set(pending.map((g) => g.gate_id)); - const gateDefs = getGatesForTurn("gate-evaluate").filter((def) => pendingIds.has(def.id)); - - const subagentSections: string[] = []; - const gateListLines: string[] = []; - - for (const def of gateDefs) { - gateListLines.push(`- **${def.id}**: ${def.question}`); - - const subPrompt = [ - `You are evaluating quality gate **${def.id}** for slice ${sid} (${sTitle}).`, - "", - `## Question: ${def.question}`, - "", - def.guidance, - "", - "## Slice Plan", - "", - planContent, - "", - "## Instructions", - "", - "Analyze the slice plan above and answer the gate question.", - `Call the \`gsd_save_gate_result\` tool with:`, - `- \`milestoneId\`: "${mid}"`, - `- \`sliceId\`: "${sid}"`, - `- \`gateId\`: "${def.id}"`, - "- `verdict`: \"pass\" (no concerns), \"flag\" (concerns found), or \"omitted\" (not applicable)", - "- `rationale`: one-sentence justification", - "- `findings`: detailed markdown findings (or empty if omitted)", - ].join("\n"); - - const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; - subagentSections.push([ - `### ${def.id}: ${def.question}`, - "", - `Use this as the prompt for a \`subagent\` call${modelSuffix}:`, - "", - "```", - subPrompt, - "```", - ].join("\n")); - } - - return loadPrompt("gate-evaluate", { - workingDirectory: base, - milestoneId: mid, - milestoneTitle: midTitle, - sliceId: sid, - sliceTitle: sTitle, - slicePlanContent: planContent, - gateCount: String(pending.length), - gateList: gateListLines.join("\n"), - subagentPrompts: subagentSections.join("\n\n---\n\n"), - }); -} - -export async function buildRewriteDocsPrompt( - mid: string, midTitle: string, - activeSlice: { id: string; title: string } | null, - base: string, - overrides: Override[], -): Promise { - const sid = activeSlice?.id; - const sTitle = activeSlice?.title ?? ""; - const docList: string[] = []; - - if (sid) { - const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); - const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); - if (slicePlanPath) { - docList.push(`- Slice plan: \`${slicePlanRel}\``); - const tDir = resolveTasksDir(base, mid, sid); - if (tDir) { - // DB primary path — get incomplete tasks - let incompleteTasks: { id: string }[] | null = null; - try { - const { isDbAvailable, getSliceTasks } = await import("./gsd-db.js"); - if (isDbAvailable()) { - incompleteTasks = getSliceTasks(mid, sid) - .filter(t => t.status !== "complete" && t.status !== "done") - .map(t => ({ id: t.id })); - } - } catch (err) { - logWarning("prompt", `buildRewriteDocsPrompt DB task lookup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - if (!incompleteTasks) { - // DB unavailable — no task data to inline - incompleteTasks = []; - } - - if (incompleteTasks) { - for (const task of incompleteTasks) { - const taskPlanPath = resolveTaskFile(base, mid, sid, task.id, "PLAN"); - if (taskPlanPath) { - const taskRelPath = `${relSlicePath(base, mid, sid)}/tasks/${task.id}-PLAN.md`; - docList.push(`- Task plan: \`${taskRelPath}\``); - } - } - } - } - } - } - - const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); - if (existsSync(decisionsPath)) docList.push(`- Decisions: \`${relGsdRootFile("DECISIONS")}\``); - const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS"); - if (existsSync(requirementsPath)) docList.push(`- Requirements: \`${relGsdRootFile("REQUIREMENTS")}\``); - const projectPath = resolveGsdRootFile(base, "PROJECT"); - if (existsSync(projectPath)) docList.push(`- Project: \`${relGsdRootFile("PROJECT")}\``); - const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); - const contextRel = relMilestoneFile(base, mid, "CONTEXT"); - if (contextPath) docList.push(`- Milestone context (reference only): \`${contextRel}\``); - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); - if (roadmapPath) docList.push(`- Roadmap: \`${roadmapRel}\``); - - const overrideContent = overrides.map((o, i) => [ - `### Override ${i + 1}`, - `**Change:** ${o.change}`, - `**Issued:** ${o.timestamp}`, - `**During:** ${o.appliedAt}`, - ].join("\n")).join("\n\n"); - - const documentList = docList.length > 0 ? docList.join("\n") : "- No active plan documents found."; - - return loadPrompt("rewrite-docs", { - milestoneId: mid, - milestoneTitle: midTitle, - sliceId: sid ?? "none", - sliceTitle: sTitle, - overrideContent, - documentList, - overridesPath: relGsdRootFile("OVERRIDES"), - }); -} diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts deleted file mode 100644 index 6e35d2ff3..000000000 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * Auto-mode Recovery — artifact resolution, verification, blocker placeholders, - * skip artifacts, merge state reconciliation, - * self-heal runtime records, and loop remediation steps. - * - * Pure functions that receive all needed state as parameters — no module-level - * globals or AutoContext dependency. - */ - -import type { ExtensionContext } from "@sf-run/pi-coding-agent"; -import { parseUnitId } from "./unit-id.js"; -import { appendEvent } from "./workflow-events.js"; -import { atomicWriteSync } from "./atomic-write.js"; -import { clearParseCache } from "./files.js"; -import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js"; -import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus } from "./gsd-db.js"; -import { isValidationTerminal } from "./state.js"; -import { getErrorMessage } from "./error-utils.js"; -import { logWarning, logError } from "./workflow-logger.js"; -import { - nativeConflictFiles, - nativeCommit, - nativeCheckoutTheirs, - nativeAddPaths, - nativeMergeAbort, - nativeResetHard, -} from "./native-git-bridge.js"; -import { - resolveSlicePath, - resolveSliceFile, - resolveTasksDir, - resolveTaskFiles, - relMilestoneFile, - relSliceFile, - buildSliceFileName, - resolveMilestoneFile, - clearPathCache, - resolveGsdRootFile, -} from "./paths.js"; -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - unlinkSync, -} from "node:fs"; -import { execFileSync } from "node:child_process"; -import { dirname, join } from "node:path"; -import { - resolveExpectedArtifactPath, - diagnoseExpectedArtifact, -} from "./auto-artifact-paths.js"; - -// Re-export so existing consumers of auto-recovery.ts keep working. -export { resolveExpectedArtifactPath, diagnoseExpectedArtifact }; - -// ─── Artifact Resolution & Verification ─────────────────────────────────────── - -/** - * Check whether a milestone produced implementation artifacts (non-`.gsd/` files) - * in the git history. Uses `git log --name-only` to inspect all commits on the - * current branch that touch files outside `.gsd/`. - * - * Returns "present" if implementation files found, "absent" if only .gsd/ files, - * "unknown" if git is unavailable or check failed (callers decide how to handle). - */ -export function hasImplementationArtifacts(basePath: string): "present" | "absent" | "unknown" { - try { - // Verify we're in a git repo - try { - execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { - cwd: basePath, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (e) { - logWarning("recovery", `git rev-parse check failed: ${(e as Error).message}`); - return "unknown"; - } - - // Strategy: check `git diff --name-only` against the merge-base with the - // main branch. This captures ALL files changed during the milestone's - // lifetime. If no merge-base exists (e.g., single-branch workflow), fall - // back to checking the last N commits. - const mainBranch = detectMainBranch(basePath); - const changedFiles = getChangedFilesSinceBranch(basePath, mainBranch); - - // No files changed at all — unknown (could be detached HEAD, single- - // commit repo, or other edge case where git diff returns nothing). - if (changedFiles.length === 0) return "unknown"; - - // Filter out .gsd/ files — only implementation files count. - // If every changed file is under .gsd/, the milestone produced no - // implementation code (#1703). - const implFiles = changedFiles.filter(f => !f.startsWith(".gsd/") && !f.startsWith(".gsd\\")); - return implFiles.length > 0 ? "present" : "absent"; - } catch (e) { - // Non-fatal — if git operations fail, return unknown so callers can decide - logWarning("recovery", `implementation artifact check failed: ${(e as Error).message}`); - return "unknown"; - } -} - -/** - * Detect the main/master branch name. - */ -function detectMainBranch(basePath: string): string { - try { - const result = execFileSync("git", ["rev-parse", "--verify", "main"], { - cwd: basePath, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.trim()) return "main"; - } catch (_) { - // Expected — main doesn't exist, try master next - void _; - } - try { - const result = execFileSync("git", ["rev-parse", "--verify", "master"], { - cwd: basePath, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.trim()) return "master"; - } catch (_) { - // Expected — master doesn't exist either - void _; - } - // Neither main nor master found — warn and fall back - logWarning("recovery", "neither main nor master branch found, defaulting to main"); - return "main"; -} - -/** - * Get files changed since the branch diverged from the target branch. - * Falls back to checking HEAD~20 if merge-base detection fails. - */ -function getChangedFilesSinceBranch(basePath: string, targetBranch: string): string[] { - try { - // Try merge-base approach first - const mergeBase = execFileSync( - "git", ["merge-base", targetBranch, "HEAD"], - { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, - ).trim(); - - if (mergeBase) { - const result = execFileSync( - "git", ["diff", "--name-only", mergeBase, "HEAD"], - { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, - ).trim(); - return result ? result.split("\n").filter(Boolean) : []; - } - } catch (err) { - // merge-base failed — fall back - logWarning("recovery", `merge-base detection failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // Fallback: check last 20 commits - try { - const result = execFileSync( - "git", ["log", "--name-only", "--pretty=format:", "-20", "HEAD"], - { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, - ).trim(); - return result ? [...new Set(result.split("\n").filter(Boolean))] : []; - } catch (e) { - logWarning("recovery", `git log fallback failed: ${(e as Error).message}`); - return []; - } -} - -/** - * Check whether the expected artifact(s) for a unit exist on disk. - * Returns true if all required artifacts exist, or if the unit type has no - * single verifiable artifact (e.g., replan-slice). - * - * complete-slice requires both SUMMARY and UAT files — verifying only - * the summary allowed the unit to be marked complete when the LLM - * skipped writing the UAT file (see #176). - */ -export function verifyExpectedArtifact( - unitType: string, - unitId: string, - base: string, -): boolean { - // Hook units have no standard artifact — always pass. Their lifecycle - // is managed by the hook engine, not the artifact verification system. - if (unitType.startsWith("hook/")) return true; - - // Clear stale directory listing cache AND parse cache so artifact checks see - // fresh disk state (#431). The parse cache must also be cleared because - // cacheKey() uses length + first/last 100 chars — when a checkbox changes - // from [ ] to [x], the key collides with the pre-edit version, returning - // stale parsed results (e.g., slice.done = false when it's actually true). - clearPathCache(); - clearParseCache(); - - if (unitType === "rewrite-docs") { - const overridesPath = resolveGsdRootFile(base, "OVERRIDES"); - if (!existsSync(overridesPath)) return true; - const content = readFileSync(overridesPath, "utf-8"); - return !content.includes("**Scope:** active"); - } - - // Reactive-execute: verify that each dispatched task's summary exists. - // The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03" - if (unitType === "reactive-execute") { - const { milestone: mid, slice: sid, task: batchPart } = parseUnitId(unitId); - if (!mid || !sid || !batchPart) return false; - const plusIdx = batchPart.indexOf("+"); - if (plusIdx === -1) { - // Legacy format "reactive" without batch IDs — fall back to "any summary" - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return false; - const summaryFiles = resolveTaskFiles(tDir, "SUMMARY"); - return summaryFiles.length > 0; - } - - const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean); - if (batchIds.length === 0) return false; - - const tDir = resolveTasksDir(base, mid, sid); - if (!tDir) return false; - - const existingSummaries = new Set( - resolveTaskFiles(tDir, "SUMMARY").map((f) => - f.replace(/-SUMMARY\.md$/i, "").toUpperCase(), - ), - ); - - // Every dispatched task must have a summary file - for (const tid of batchIds) { - if (!existingSummaries.has(tid.toUpperCase())) return false; - } - return true; - } - - // Gate-evaluate: verify that each dispatched gate has been resolved in the DB. - // The unitId encodes the batch: "{mid}/{sid}/gates+Q3,Q4" - if (unitType === "gate-evaluate") { - const { milestone: mid, slice: sid, task: batchPart } = parseUnitId(unitId); - if (!mid || !sid || !batchPart) return false; - - const plusIdx = batchPart.indexOf("+"); - if (plusIdx === -1) return true; // no specific gates encoded — pass - - const gateIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean); - if (gateIds.length === 0) return true; - - try { - const pending = getPendingGates(mid, sid, "slice"); - const pendingIds = new Set(pending.map((g: any) => g.gate_id)); - // All dispatched gates must no longer be pending - for (const gid of gateIds) { - if (pendingIds.has(gid)) return false; - } - } catch (err) { - // DB unavailable — treat as verified to avoid blocking - logWarning("recovery", `gate-evaluate DB check failed: ${err instanceof Error ? err.message : String(err)}`); - } - return true; - } - - const absPath = resolveExpectedArtifactPath(unitType, unitId, base); - // For unit types with no verifiable artifact (null path), the parent directory - // is missing on disk — treat as stale completion state so the key gets evicted (#313). - if (!absPath) return false; - if (!existsSync(absPath)) return false; - - if (unitType === "validate-milestone") { - const validationContent = readFileSync(absPath, "utf-8"); - if (!isValidationTerminal(validationContent)) return false; - } - - if (unitType === "plan-milestone") { - try { - const roadmap = parseLegacyRoadmap(readFileSync(absPath, "utf-8")); - if (roadmap.slices.length === 0) return false; - } catch (err) { - logWarning("recovery", `plan-milestone roadmap verification failed: ${err instanceof Error ? err.message : String(err)}`); - return false; - } - } - - // plan-slice must produce a plan with actual task entries, not just a scaffold. - // The plan file may exist from a prior discussion/context step with only headings - // but no tasks. Without this check the artifact is considered "complete" and the - // unit gets skipped — but deriveState still returns phase:"planning" because the - // plan has no tasks, creating an infinite skip loop (#699). - if (unitType === "plan-slice") { - const planContent = readFileSync(absPath, "utf-8"); - // Accept checkbox-style (- [x] **T01: ...) or heading-style (### T01 -- / ### T01: / ### T01 —) - const hasCheckboxTask = /^- \[[xX ]\] \*\*T\d+:/m.test(planContent); - const hasHeadingTask = /^#{2,4}\s+T\d+\s*(?:--|—|:)/m.test(planContent); - if (!hasCheckboxTask && !hasHeadingTask) return false; - } - - // execute-task: DB status is authoritative. Fall back to checked-checkbox - // detection when the DB is unavailable (unmigrated projects). - if (unitType === "execute-task") { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (mid && sid && tid) { - const dbTask = getTask(mid, sid, tid); - if (dbTask) { - // DB available — trust it - if (dbTask.status !== "complete" && dbTask.status !== "done") return false; - } else if (!isDbAvailable()) { - // LEGACY: Pre-migration fallback for projects without DB. - // Require a CHECKED checkbox — a bare heading or unchecked checkbox - // does not prove gsd_complete_task ran. Summary file on disk alone - // is not sufficient evidence (could be a rogue write) (#3607). - const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); - if (planAbs && existsSync(planAbs)) { - const planContent = readFileSync(planAbs, "utf-8"); - const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const cbRe = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m"); - if (!cbRe.test(planContent)) return false; - } else { - return false; // no plan file → cannot verify - } - } else { - // DB available but task row not found — completion tool never ran (#3607) - return false; - } - } - } - - // plan-slice must also produce individual task plan files for every task listed - // in the slice plan. Without this check, a plan-slice that wrote S{sid}-PLAN.md - // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task - // to dispatch with a missing task plan (see issue #739). - if (unitType === "plan-slice") { - const { milestone: mid, slice: sid } = parseUnitId(unitId); - if (mid && sid) { - try { - // DB primary path — get task IDs to verify task plan files exist - let taskIds: string[] | null = null; - if (isDbAvailable()) { - const tasks = getSliceTasks(mid, sid); - if (tasks.length > 0) taskIds = tasks.map(t => t.id); - } - - if (!taskIds) { - // LEGACY: DB unavailable or no tasks in DB — parse plan file for task IDs - const planContent = readFileSync(absPath, "utf-8"); - const plan = parseLegacyPlan(planContent); - if (plan.tasks.length > 0) taskIds = plan.tasks.map((t: { id: string }) => t.id); - } - - if (taskIds && taskIds.length > 0) { - const tasksDir = resolveTasksDir(base, mid, sid); - if (tasksDir) { - for (const tid of taskIds) { - const taskPlanFile = join(tasksDir, `${tid}-PLAN.md`); - if (!existsSync(taskPlanFile)) return false; - } - } - } - } catch (err) { - // Parse failure — don't block; slice plan may have non-standard format - logWarning("recovery", `plan-slice task plan verification failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // complete-slice: DB status is authoritative for whether the slice is done. - // Fall back to file-based check (roadmap [x]) when DB is unavailable. - if (unitType === "complete-slice") { - const { milestone: mid, slice: sid } = parseUnitId(unitId); - if (mid && sid) { - const dir = resolveSlicePath(base, mid, sid); - if (dir) { - const uatPath = join(dir, buildSliceFileName(sid, "UAT")); - if (!existsSync(uatPath)) return false; - } - - const dbSlice = getSlice(mid, sid); - if (dbSlice) { - // DB available — trust it - if (dbSlice.status !== "complete") return false; - } else if (!isDbAvailable()) { - // LEGACY: Pre-migration fallback for projects without DB. - // Fall back to roadmap checkbox check via parsers-legacy - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - if (roadmapFile && existsSync(roadmapFile)) { - try { - const roadmapContent = readFileSync(roadmapFile, "utf-8"); - const roadmap = parseLegacyRoadmap(roadmapContent); - const slice = roadmap.slices.find((s) => s.id === sid); - if (slice && !slice.done) return false; - } catch (e) { - logWarning("recovery", `roadmap parse failed: ${(e as Error).message}`); - return false; - } - } - } - // else: DB available but slice not found — summary + UAT exist, - // treat as verified (slice may not be imported yet) - } - } - - // complete-milestone must have produced implementation artifacts (#1703). - // A milestone with only .gsd/ plan files and zero implementation code is - // not genuinely complete — the LLM wrote plan files but skipped actual work. - if (unitType === "complete-milestone") { - if (hasImplementationArtifacts(base) === "absent") return false; - } - - return true; -} - -/** - * Write a placeholder artifact so the pipeline can advance past a stuck unit. - * Returns the relative path written, or null if the path couldn't be resolved. - */ -export function writeBlockerPlaceholder( - unitType: string, - unitId: string, - base: string, - reason: string, -): string | null { - const absPath = resolveExpectedArtifactPath(unitType, unitId, base); - if (!absPath) return null; - const dir = dirname(absPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const content = [ - `# BLOCKER — auto-mode recovery failed`, - ``, - `Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`, - ``, - `**Reason**: ${reason}`, - ``, - `This placeholder was written by auto-mode so the pipeline can advance.`, - `Review and replace this file before relying on downstream artifacts.`, - ].join("\n"); - writeFileSync(absPath, content, "utf-8"); - - // Mark the task/slice as complete in the DB so verifyExpectedArtifact passes. - // Without this, the DB status stays "pending" and the dispatch loop - // re-derives the same unit indefinitely (#2531, #2653). - if (isDbAvailable()) { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - const ts = new Date().toISOString(); - if (unitType === "execute-task" && mid && sid && tid) { - try { updateTaskStatus(mid, sid, tid, "complete", ts); } catch (e) { logWarning("recovery", `updateTaskStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } - // Append event so worktree reconciliation can replay this recovery completion - try { appendEvent(base, { cmd: "complete-task", params: { milestoneId: mid, sliceId: sid, taskId: tid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch (e) { logWarning("recovery", `appendEvent failed for task recovery: ${e instanceof Error ? e.message : String(e)}`); } - } - if (unitType === "complete-slice" && mid && sid) { - try { updateSliceStatus(mid, sid, "complete", ts); } catch (e) { logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } - try { appendEvent(base, { cmd: "complete-slice", params: { milestoneId: mid, sliceId: sid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch (e) { logWarning("recovery", `appendEvent failed for slice recovery: ${e instanceof Error ? e.message : String(e)}`); } - } - } - - return diagnoseExpectedArtifact(unitType, unitId, base); -} - -// ─── Merge State Reconciliation ─────────────────────────────────────────────── - -/** - * Best-effort abort of a pending merge/squash and hard-reset to HEAD. - * Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG). - */ -function abortAndResetMerge( - basePath: string, - hasMergeHead: boolean, - squashMsgPath: string, -): void { - if (hasMergeHead) { - try { - nativeMergeAbort(basePath); - } catch (err) { - /* best-effort */ - logWarning("recovery", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`); - } - } else if (squashMsgPath) { - try { - unlinkSync(squashMsgPath); - } catch (err) { - /* best-effort */ - logWarning("recovery", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - try { - nativeResetHard(basePath); - } catch (err) { - /* best-effort */ - logError("recovery", `git reset failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -export type MergeReconcileResult = "clean" | "reconciled" | "blocked"; - -/** - * Detect leftover merge state from a prior session and reconcile it. - * If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved. - * If resolved: finalize the commit. If only .gsd conflicts remain: auto-resolve. - * If code conflicts remain: fail safe without modifying the worktree. - */ -export function reconcileMergeState( - basePath: string, - ctx: ExtensionContext, -): MergeReconcileResult { - const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD"); - const squashMsgPath = join(basePath, ".git", "SQUASH_MSG"); - const hasMergeHead = existsSync(mergeHeadPath); - const hasSquashMsg = existsSync(squashMsgPath); - if (!hasMergeHead && !hasSquashMsg) return "clean"; - - const conflictedFiles = nativeConflictFiles(basePath); - if (conflictedFiles.length === 0) { - // All conflicts resolved — finalize the merge/squash commit - try { - const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state"); - if (commitSha) { - const mode = hasMergeHead ? "merge" : "squash commit"; - ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info"); - } else { - ctx.ui.notify("No new commit needed for leftover merge/squash state — already committed.", "info"); - } - } catch (err) { - const errorMessage = getErrorMessage(err); - ctx.ui.notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error"); - return "blocked"; - } - } else { - // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530) - const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/")); - const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/")); - - if (gsdConflicts.length > 0 && codeConflicts.length === 0) { - // All conflicts are in .gsd/ state files — auto-resolve by accepting theirs - let resolved = true; - try { - nativeCheckoutTheirs(basePath, gsdConflicts); - nativeAddPaths(basePath, gsdConflicts); - } catch (e) { - logError("recovery", `auto-resolve .gsd/ conflicts failed: ${(e as Error).message}`); - resolved = false; - } - if (resolved) { - try { - nativeCommit( - basePath, - "chore: auto-resolve .gsd/ state file conflicts", - ); - ctx.ui.notify( - `Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, - "info", - ); - } catch (e) { - logError("recovery", `auto-commit .gsd/ conflict resolution failed: ${(e as Error).message}`); - resolved = false; - } - } - if (!resolved) { - abortAndResetMerge(basePath, hasMergeHead, squashMsgPath); - ctx.ui.notify( - "Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", - "warning", - ); - } - } else { - // Code conflicts present — fail safe and preserve any manual resolution - // work instead of discarding it with merge --abort/reset --hard. - ctx.ui.notify( - "Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.", - "error", - ); - return "blocked"; - } - } - return "reconciled"; -} - -// ─── Loop Remediation ───────────────────────────────────────────────────────── - -/** - * Build concrete, manual remediation steps for a loop-detected unit failure. - * These are shown when automatic reconciliation is not possible. - */ -export function buildLoopRemediationSteps( - unitType: string, - unitId: string, - base: string, -): string | null { - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - switch (unitType) { - case "execute-task": { - if (!mid || !sid || !tid) break; - return [ - ` 1. Run \`gsd undo-task ${tid}\` to reset the task state`, - ` 2. Resume auto-mode — it will re-execute the task`, - ` 3. If the task keeps failing, run \`gsd recover\` to rebuild DB state from disk`, - ].join("\n"); - } - case "plan-slice": - case "research-slice": { - if (!mid || !sid) break; - const artifactRel = - unitType === "plan-slice" - ? relSliceFile(base, mid, sid, "PLAN") - : relSliceFile(base, mid, sid, "RESEARCH"); - return [ - ` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`, - ` 2. Run \`gsd recover\` to rebuild DB state from disk`, - ` 3. Resume auto-mode`, - ].join("\n"); - } - case "complete-slice": { - if (!mid || !sid) break; - return [ - ` 1. Run \`gsd reset-slice ${sid}\` to reset the slice and all its tasks`, - ` 2. Resume auto-mode — it will re-execute incomplete tasks and re-complete the slice`, - ` 3. If the slice keeps failing, run \`gsd recover\` to rebuild DB state from disk`, - ].join("\n"); - } - case "validate-milestone": { - if (!mid) break; - const artifactRel = relMilestoneFile(base, mid, "VALIDATION"); - return [ - ` 1. Write ${artifactRel} with verdict: pass`, - ` 2. Run \`gsd recover\` to rebuild DB state from disk`, - ` 3. Resume auto-mode`, - ].join("\n"); - } - default: - break; - } - return null; -} diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts deleted file mode 100644 index 14cf06ebc..000000000 --- a/src/resources/extensions/gsd/auto-start.ts +++ /dev/null @@ -1,962 +0,0 @@ -/** - * Auto-mode bootstrap — fresh-start initialization path. - * - * Git/state bootstrap, crash lock detection, debug init, worktree recovery, - * guided flow gate, session init, worktree lifecycle, DB lifecycle, - * preflight validation. - * - * Extracted from startAuto() in auto.ts. The resume path (s.paused) - * remains in auto.ts — this module handles only the fresh-start path. - */ - -import type { - ExtensionAPI, - ExtensionCommandContext, -} from "@sf-run/pi-coding-agent"; -import { deriveState } from "./state.js"; -import { loadFile, getManifestStatus } from "./files.js"; -import type { InterruptedSessionAssessment } from "./interrupted-session.js"; -import { - loadEffectiveGSDPreferences, - resolveSkillDiscoveryMode, - getIsolationMode, - resolvePersistModelChanges, -} from "./preferences.js"; -import { ensureGsdSymlink, isInheritedRepo, validateProjectId } from "./repo-identity.js"; -import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js"; -import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; -import { gsdRoot, resolveMilestoneFile } from "./paths.js"; -import { invalidateAllCaches } from "./cache.js"; -import { writeLock, clearLock } from "./crash-recovery.js"; -import { - acquireSessionLock, - releaseSessionLock, - updateSessionLock, -} from "./session-lock.js"; -import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; -import { - nativeIsRepo, - nativeInit, - nativeAddAll, - nativeCommit, - nativeGetCurrentBranch, - nativeDetectMainBranch, - nativeCheckoutBranch, - nativeBranchList, - nativeBranchListMerged, - nativeBranchDelete, - nativeWorktreeRemove, -} from "./native-git-bridge.js"; -import { GitServiceImpl } from "./git-service.js"; -import { - captureIntegrationBranch, - detectWorktreeName, - setActiveMilestoneId, -} from "./worktree.js"; -import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js"; -import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js"; -import { worktreePath as getWorktreeDir, isInsideWorktreesDir } from "./worktree-manager.js"; -import { initMetrics } from "./metrics.js"; -import { initRoutingHistory } from "./routing-history.js"; -import { restoreHookState, resetHookState } from "./post-unit-hooks.js"; -import { resetProactiveHealing, setLevelChangeCallback } from "./doctor-proactive.js"; -import { snapshotSkills } from "./skill-discovery.js"; -import { isDbAvailable, getMilestone, openDatabase } from "./gsd-db.js"; -import { hideFooter } from "./auto-dashboard.js"; -import { - debugLog, - enableDebug, - isDebugEnabled, - getDebugLogPath, -} from "./debug-logger.js"; -import { logWarning, logError } from "./workflow-logger.js"; -import { parseUnitId } from "./unit-id.js"; -import type { AutoSession } from "./auto/session.js"; -import { - existsSync, - mkdirSync, - readdirSync, - rmSync, - statSync, - unlinkSync, -} from "node:fs"; -import { join } from "node:path"; -import { sep as pathSep } from "node:path"; - -import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js"; -import { - isCustomProvider, - resolveDefaultSessionModel, - resolveDynamicRoutingConfig, -} from "./preferences-models.js"; -import type { WorktreeResolver } from "./worktree-resolver.js"; -import { getSessionModelOverride } from "./session-model-override.js"; - -export interface BootstrapDeps { - shouldUseWorktreeIsolation: () => boolean; - registerSigtermHandler: (basePath: string) => void; - lockBase: () => string; - buildResolver: () => WorktreeResolver; -} - -/** - * Bootstrap a fresh auto-mode session. Handles everything from git init - * through secrets collection, returning when ready for the first - * dispatchNextUnit call. - * - * Returns false if the bootstrap aborted (e.g., guided flow returned, - * concurrent session detected). Returns true when ready to dispatch. - */ - -// Guard constant for consecutive bootstrap attempts that found phase === "complete". -// Counter moved to AutoSession.consecutiveCompleteBootstraps so s.reset() clears it. -const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2; - -export async function openProjectDbIfPresent(basePath: string): Promise { - const gsdDbPath = resolveProjectRootDbPath(basePath); - if (!existsSync(gsdDbPath) || isDbAvailable()) return; - - try { - openDatabase(gsdDbPath); - } catch (err) { - logWarning("engine", `gsd-db: failed to open existing database: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Audit for orphaned milestone branches at bootstrap. - * - * After a milestone completes, the teardown step (merge branch → main, - * delete branch, remove worktree) runs as a post-completion engine step. - * If the session ends between completion and teardown, the branch and - * worktree are orphaned — the DB says "complete" so auto-mode won't - * re-enter the milestone, and the teardown is never retried. - * - * This audit runs on every fresh bootstrap to catch that gap: - * 1. Lists all local `milestone/*` branches. - * 2. For each, checks if the milestone's DB status is "complete". - * 3. If the branch is already merged into main → deletes the branch - * and cleans up any orphaned worktree directory (safe, no data loss). - * 4. If the branch is NOT merged → preserves it and warns the user - * so they can merge manually (data safety first). - * - * Returns a summary of actions taken for the caller to surface via notify. - */ -export function auditOrphanedMilestoneBranches( - basePath: string, - isolationMode: "worktree" | "branch" | "none", -): { recovered: string[]; warnings: string[] } { - const recovered: string[] = []; - const warnings: string[] = []; - - // Skip in none mode — no milestone branches are created - if (isolationMode === "none") return { recovered, warnings }; - - // Skip if DB not available — can't determine completion status - if (!isDbAvailable()) return { recovered, warnings }; - - let milestoneBranches: string[]; - try { - milestoneBranches = nativeBranchList(basePath, "milestone/*"); - } catch { - // git branch list failed — skip audit - return { recovered, warnings }; - } - - if (milestoneBranches.length === 0) return { recovered, warnings }; - - // Detect main branch for merge-check - let mainBranch: string; - try { - mainBranch = nativeDetectMainBranch(basePath); - } catch { - mainBranch = "main"; - } - - // Get branches already merged into main - let mergedBranches: Set; - try { - mergedBranches = new Set(nativeBranchListMerged(basePath, mainBranch, "milestone/*")); - } catch { - mergedBranches = new Set(); - } - - for (const branch of milestoneBranches) { - const milestoneId = branch.replace(/^milestone\//, ""); - const milestone = getMilestone(milestoneId); - - // Only audit completed milestones - if (!milestone || milestone.status !== "complete") continue; - - const isMerged = mergedBranches.has(branch); - - if (isMerged) { - // Branch is merged — safe to delete branch and clean up worktree dir - try { - nativeBranchDelete(basePath, branch, true); - recovered.push(`Deleted merged branch ${branch} for completed milestone ${milestoneId}.`); - } catch (err) { - warnings.push(`Failed to delete merged branch ${branch}: ${err instanceof Error ? err.message : String(err)}`); - } - - // Clean up orphaned worktree directory if it exists - const wtDir = getWorktreeDir(basePath, milestoneId); - if (existsSync(wtDir)) { - // Try git worktree remove first (handles registered worktrees) - try { - nativeWorktreeRemove(basePath, wtDir, true); - } catch (e) { - // Not a registered worktree — expected for orphaned dirs - logWarning("engine", `worktree remove failed (expected for orphaned dirs): ${e instanceof Error ? e.message : String(e)}`); - } - - // If the directory still exists after git worktree remove (either it - // wasn't registered or the remove was a noop), fall back to direct - // filesystem removal — but only inside .gsd/worktrees/ for safety (#2365). - if (existsSync(wtDir)) { - if (isInsideWorktreesDir(basePath, wtDir)) { - try { - rmSync(wtDir, { recursive: true, force: true }); - recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`); - } catch (err2) { - warnings.push(`Failed to remove worktree directory for ${milestoneId}: ${err2 instanceof Error ? err2.message : String(err2)}`); - } - } else { - warnings.push(`Orphaned worktree directory for ${milestoneId} is outside .gsd/worktrees/ — skipping removal for safety.`); - } - } else { - recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`); - } - } - } else { - // Branch is NOT merged — preserve for safety, warn the user - warnings.push( - `Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` + - `This may contain unmerged work. Merge manually or run \`/gsd health --fix\` to resolve.`, - ); - } - } - - return { recovered, warnings }; -} - -export async function bootstrapAutoSession( - s: AutoSession, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - base: string, - verboseMode: boolean, - requestedStepMode: boolean, - deps: BootstrapDeps, - interrupted: InterruptedSessionAssessment, -): Promise { - const { - shouldUseWorktreeIsolation, - registerSigtermHandler, - lockBase, - buildResolver, - } = deps; - - const lockResult = acquireSessionLock(base); - if (!lockResult.acquired) { - ctx.ui.notify(lockResult.reason, "error"); - return false; - } - - function releaseLockAndReturn(): false { - releaseSessionLock(base); - clearLock(base); - return false; - } - - // Capture the user's session model before guided-flow dispatch can apply a - // phase-specific planning model for a discuss turn (#2829). - // - // Precedence: - // 1) Explicit session override via /gsd model (this session) - // 2) SF model preferences from PREFERENCES.md (validated against live auth) - // 3) Current session model from settings/session restore (if provider ready) - // - // This preserves #3517 defaults while honoring explicit runtime model - // selection for subsequent /gsd runs in the same session. - // - // Exception (#4122): when the session provider is a custom provider declared - // in ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.), - // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom - // providers, so honoring it would silently reroute auto-mode to a built-in - // provider the user is not logged into and surface as "Not logged in · Please - // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6. - const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId()); - const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider); - const preferredModel = sessionProviderIsCustom - ? null - : resolveDefaultSessionModel(ctx.model?.provider); - // Validate the preferred model against the live registry + provider auth so - // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the - // start-model snapshot. Without this, every subsequent unit would try to - // fall back to an unusable model. - let validatedPreferredModel: { provider: string; id: string } | undefined; - if (preferredModel) { - const { resolveModelId } = await import("./auto-model-selection.js"); - const available = ctx.modelRegistry.getAvailable(); - const match = resolveModelId( - `${preferredModel.provider}/${preferredModel.id}`, - available, - ctx.model?.provider, - ); - if (match) { - validatedPreferredModel = { provider: match.provider, id: match.id }; - } else { - ctx.ui.notify( - `Preferred model ${preferredModel.provider}/${preferredModel.id} from PREFERENCES.md is not configured; falling back to session default.`, - "warning", - ); - } - } - const sessionModelReady = - ctx.model && ctx.modelRegistry.isProviderRequestReady(ctx.model.provider); - const startModelSnapshot = manualSessionOverride - ?? validatedPreferredModel - ?? (sessionModelReady && ctx.model - ? { provider: ctx.model.provider, id: ctx.model.id } - : null); - - try { - // Validate SF_PROJECT_ID early so the user gets immediate feedback - const customProjectId = process.env.SF_PROJECT_ID; - if (customProjectId && !validateProjectId(customProjectId)) { - ctx.ui.notify( - `SF_PROJECT_ID must contain only alphanumeric characters, hyphens, and underscores. Got: "${customProjectId}"`, - "error", - ); - return releaseLockAndReturn(); - } - - // Ensure git repo exists *locally* at base. - // nativeIsRepo() uses `git rev-parse` which traverses up to parent dirs, - // so a parent repo can make it return true even when base has no .git of - // its own. Check for a local .git instead (defense-in-depth for the case - // where isInheritedRepo() returns a false negative, e.g. stale .gsd at - // the parent git root). See #2393 and related issue. - const hasLocalGit = existsSync(join(base, ".git")); - if (!hasLocalGit || isInheritedRepo(base)) { - const mainBranch = - loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; - nativeInit(base, mainBranch); - } - - // Migrate legacy in-project .gsd/ to external state directory. - // Migration MUST run before ensureGitignore to avoid adding ".gsd" to - // .gitignore when .gsd/ is git-tracked (data-loss bug #1364). - recoverFailedMigration(base); - const migration = migrateToExternalState(base); - if (migration.error) { - ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning"); - } - // Ensure symlink exists (handles fresh projects and post-migration) - ensureGsdSymlink(base); - - // Ensure .gitignore has baseline patterns. - // ensureGitignore checks for git-tracked .gsd/ files and skips the - // ".gsd" pattern if the project intentionally tracks .gsd/ in git. - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; - const manageGitignore = gitPrefs?.manage_gitignore; - ensureGitignore(base, { manageGitignore }); - if (manageGitignore !== false) untrackRuntimeFiles(base); - - // Bootstrap milestones/ if it doesn't exist. - // Check milestones/ directly — ensureGsdSymlink above already created .gsd/, - // so checking .gsd/ existence would be dead code (#2942). - const gsdDir = join(base, ".gsd"); - const milestonesPath = join(gsdDir, "milestones"); - if (!existsSync(milestonesPath)) { - mkdirSync(milestonesPath, { recursive: true }); - try { - nativeAddAll(base); - nativeCommit(base, "chore: init gsd"); - } catch (err) { - /* nothing to commit */ - logWarning("engine", `mkdir failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - { - const { prepareWorkflowMcpForProject } = await import("./workflow-mcp-auto-prep.js"); - prepareWorkflowMcpForProject(ctx, base); - } - - // Initialize GitServiceImpl - s.gitService = new GitServiceImpl( - s.basePath, - loadEffectiveGSDPreferences()?.preferences?.git ?? {}, - ); - - // ── Debug mode ── - if (!isDebugEnabled() && process.env.SF_DEBUG === "1") { - enableDebug(base); - } - if (isDebugEnabled()) { - const { isNativeParserAvailable } = - await import("./native-parser-bridge.js"); - debugLog("debug-start", { - platform: process.platform, - arch: process.arch, - node: process.version, - model: ctx.model?.id ?? "unknown", - provider: ctx.model?.provider ?? "unknown", - nativeParser: isNativeParserAvailable(), - cwd: base, - }); - ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info"); - } - - if (interrupted.classification !== "recoverable") { - s.pendingCrashRecovery = null; - } - - // Invalidate caches before initial state derivation - invalidateAllCaches(); - - // Clean stale runtime unit files for completed milestones (#887) - cleanStaleRuntimeUnits( - gsdRoot(base), - (mid) => !!resolveMilestoneFile(base, mid, "SUMMARY"), - ); - - // Open the project-root DB before deriveState so DB-backed state - // derivation (queue-order, task status) works on a cold start (#2841). - await openProjectDbIfPresent(base); - - // ── Orphaned milestone branch audit ── - // Catches completed milestones whose teardown (merge + branch delete) - // was lost due to session ending between completion and teardown. - // Must run after DB open and before worktree entry. - try { - const auditResult = auditOrphanedMilestoneBranches(base, getIsolationMode()); - for (const msg of auditResult.recovered) { - ctx.ui.notify(`Orphan audit: ${msg}`, "info"); - } - for (const msg of auditResult.warnings) { - ctx.ui.notify(`Orphan audit: ${msg}`, "warning"); - } - if (auditResult.recovered.length > 0) { - debugLog("orphan-audit", { recovered: auditResult.recovered, warnings: auditResult.warnings }); - } - } catch (err) { - // Non-fatal — the audit is defensive, never block bootstrap - logWarning("bootstrap", `orphaned milestone branch audit failed: ${err instanceof Error ? err.message : String(err)}`); - } - - let state = await deriveState(base); - - // Stale worktree state recovery (#654) - if ( - state.activeMilestone && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) - ) { - const wtPath = getAutoWorktreePath(base, state.activeMilestone.id); - if (wtPath) { - state = await deriveState(wtPath); - } - } - - // Milestone branch recovery (#601, #2358) - // Detect survivor milestone branches in both pre-planning and complete phases. - // In phase=complete, the milestone artifacts exist but finalization (merge, - // worktree cleanup) was never run — the survivor branch must be merged. - let hasSurvivorBranch = false; - if ( - state.activeMilestone && - (state.phase === "pre-planning" || state.phase === "complete") && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) && - !base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`) - ) { - const milestoneBranch = `milestone/${state.activeMilestone.id}`; - const { nativeBranchExists } = await import("./native-git-bridge.js"); - hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); - if (hasSurvivorBranch) { - ctx.ui.notify( - `Found prior session branch ${milestoneBranch}. Resuming.`, - "info", - ); - } - } - - // Survivor branch exists but milestone still needs discussion (#1726): - // The worktree/branch was created but the milestone only has CONTEXT-DRAFT.md. - // Route to the interactive discussion handler instead of falling through to - // auto-mode, which would immediately stop with "needs discussion". - if (hasSurvivorBranch && state.phase === "needs-discussion") { - const { showWorkflowEntry } = await import("./guided-flow.js"); - await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); - - invalidateAllCaches(); - const postState = await deriveState(base); - if ( - postState.activeMilestone && - postState.phase !== "needs-discussion" - ) { - state = postState; - // Discussion succeeded — clear survivor flag so normal flow continues - hasSurvivorBranch = false; - } else { - ctx.ui.notify( - "Discussion completed but milestone draft was not promoted. Run /gsd to try again.", - "warning", - ); - return releaseLockAndReturn(); - } - } - - // Survivor branch exists and milestone is complete (#2358): - // The milestone artifacts were written but finalization (merge, worktree - // cleanup) never ran. Run mergeAndExit to finalize, then re-derive state - // so the normal "all milestones complete" or "next milestone" path runs. - if (hasSurvivorBranch && state.phase === "complete") { - const mid = state.activeMilestone!.id; - ctx.ui.notify( - `Milestone ${mid} is complete but branch/worktree was not finalized. Running merge now.`, - "info", - ); - const resolver = buildResolver(); - resolver.mergeAndExit(mid, { - notify: ctx.ui.notify.bind(ctx.ui), - }); - invalidateAllCaches(); - state = await deriveState(base); - // Clear survivor flag — finalization is done - hasSurvivorBranch = false; - } - - if (!hasSurvivorBranch) { - // No active work — start a new milestone via discuss flow - if (!state.activeMilestone || state.phase === "complete") { - // Guard against recursive dialog loop (#1348): - // If we've entered this branch multiple times in quick succession, - // the discuss workflow isn't producing a milestone. Break the cycle. - s.consecutiveCompleteBootstraps++; - if (s.consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) { - s.consecutiveCompleteBootstraps = 0; - ctx.ui.notify( - "All milestones are complete and the discussion didn't produce a new one. " + - "Run /gsd to start a new milestone manually.", - "warning", - ); - return releaseLockAndReturn(); - } - - const { showWorkflowEntry } = await import("./guided-flow.js"); - await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); - - invalidateAllCaches(); - const postState = await deriveState(base); - if ( - postState.activeMilestone && - postState.phase !== "complete" && - postState.phase !== "pre-planning" - ) { - s.consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete" - state = postState; - } else if ( - postState.activeMilestone && - postState.phase === "pre-planning" - ) { - const contextFile = resolveMilestoneFile( - base, - postState.activeMilestone.id, - "CONTEXT", - ); - const hasContext = !!(contextFile && (await loadFile(contextFile))); - if (hasContext) { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", - "warning", - ); - return releaseLockAndReturn(); - } - } else { - return releaseLockAndReturn(); - } - } - - // Active milestone exists but has no roadmap - if (state.phase === "pre-planning") { - const mid = state.activeMilestone!.id; - const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); - const hasContext = !!(contextFile && (await loadFile(contextFile))); - if (!hasContext) { - const { showWorkflowEntry } = await import("./guided-flow.js"); - await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); - - invalidateAllCaches(); - const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "pre-planning") { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but milestone context is still missing. Run /gsd to try again.", - "warning", - ); - return releaseLockAndReturn(); - } - } - } - - // Active milestone has CONTEXT-DRAFT but no full context — needs discussion - if (state.phase === "needs-discussion") { - const { showWorkflowEntry } = await import("./guided-flow.js"); - await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); - - invalidateAllCaches(); - const postState = await deriveState(base); - if ( - postState.activeMilestone && - postState.phase !== "needs-discussion" - ) { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but milestone draft was not promoted. Run /gsd to try again.", - "warning", - ); - return releaseLockAndReturn(); - } - } - } - - // Unreachable safety check - if (!state.activeMilestone) { - const { showWorkflowEntry } = await import("./guided-flow.js"); - await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); - return releaseLockAndReturn(); - } - - // Successfully resolved an active milestone — reset the re-entry guard - s.consecutiveCompleteBootstraps = 0; - - // ── Initialize session state ── - // Notify shared phase state so subagent conflict checks can fire - const { activateGSD: activateGSDPhaseState } = await import("../shared/gsd-phase-state.js"); - activateGSDPhaseState(); - s.active = true; - s.stepMode = requestedStepMode; - s.verbose = verboseMode; - s.cmdCtx = ctx; - s.basePath = base; - s.unitDispatchCount.clear(); - s.unitRecoveryCount.clear(); - s.lastBudgetAlertLevel = 0; - s.unitLifetimeDispatches.clear(); - resetHookState(); - restoreHookState(base); - resetProactiveHealing(); - // Notify user on health level transitions (green→yellow→red and back) - setLevelChangeCallback((_from, to, summary) => { - const level = to === "red" ? "error" : to === "yellow" ? "warning" : "info"; - ctx.ui.notify(summary, level as "info" | "warning" | "error"); - }); - s.autoStartTime = Date.now(); - s.resourceVersionOnStart = readResourceVersion(); - s.pendingQuickTasks = []; - s.currentUnit = null; - s.currentMilestoneId = state.activeMilestone?.id ?? null; - s.originalModelId = ctx.model?.id ?? null; - s.originalModelProvider = ctx.model?.provider ?? null; - - // Register SIGTERM handler - registerSigtermHandler(base); - - // Capture integration branch - if (s.currentMilestoneId) { - if (getIsolationMode() !== "none") { - captureIntegrationBranch(base, s.currentMilestoneId); - } - setActiveMilestoneId(base, s.currentMilestoneId); - } - - // Guard against stale milestone branch when isolation:none (#3613). - // A prior session with isolation:branch/worktree may have left HEAD on - // milestone/. Auto-checkout back to the integration branch. - if (getIsolationMode() === "none" && nativeIsRepo(base)) { - try { - const currentBranch = nativeGetCurrentBranch(base); - if (currentBranch.startsWith("milestone/")) { - const integrationBranch = nativeDetectMainBranch(base); - nativeCheckoutBranch(base, integrationBranch); - logWarning("bootstrap", `Returned to "${integrationBranch}" — HEAD was on stale milestone branch "${currentBranch}" (isolation: none does not use milestone branches).`); - } - } catch (err) { - logWarning("bootstrap", `Could not auto-checkout from stale milestone branch: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // ── Auto-worktree setup ── - s.originalBasePath = base; - - const isUnderGsdWorktrees = (p: string): boolean => { - // Direct layout: /.gsd/worktrees/ - const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; - if (p.includes(marker)) return true; - const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`; - if (p.endsWith(worktreesSuffix)) return true; - // Symlink-resolved layout: /.gsd/projects//worktrees/ - const symlinkRe = new RegExp( - `\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`, - ); - return symlinkRe.test(p); - }; - - if ( - s.currentMilestoneId && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) && - !isUnderGsdWorktrees(base) - ) { - buildResolver().enterMilestone(s.currentMilestoneId, { - notify: ctx.ui.notify.bind(ctx.ui), - }); - if (s.basePath !== base) { - // Successfully entered worktree — re-register SIGTERM handler at original base - registerSigtermHandler(s.originalBasePath); - } - } - - // ── DB lifecycle ── - const gsdDbPath = resolveProjectRootDbPath(s.basePath); - const gsdDirPath = join(s.basePath, ".gsd"); - if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) { - const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md")); - const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md")); - const hasMilestones = existsSync(join(gsdDirPath, "milestones")); - try { - const { openDatabase: openDb } = await import("./gsd-db.js"); - openDb(gsdDbPath); - if (hasDecisions || hasRequirements || hasMilestones) { - const { migrateFromMarkdown } = await import("./md-importer.js"); - migrateFromMarkdown(s.basePath); - } - } catch (err) { - logError("engine", `auto-migration failed: ${(err as Error).message}`); - } - } - if (existsSync(gsdDbPath) && !isDbAvailable()) { - try { - const { openDatabase: openDb } = await import("./gsd-db.js"); - openDb(gsdDbPath); - } catch (err) { - logError("engine", `failed to open existing database: ${(err as Error).message}`); - } - } - - // Gate: abort bootstrap if the DB file exists but the provider is - // still unavailable after both open attempts above. Without this, - // auto-mode starts but every gsd_task_complete / gsd_slice_complete - // call returns "db_unavailable", triggering artifact-retry which - // re-dispatches the same task — producing an infinite loop (#2419). - if (existsSync(gsdDbPath) && !isDbAvailable()) { - ctx.ui.notify( - "SQLite database exists but failed to open. Auto-mode cannot proceed without a working database provider. " + - "Check for corrupt gsd.db or missing native SQLite bindings.", - "error", - ); - return releaseLockAndReturn(); - } - - // Initialize metrics - initMetrics(s.basePath); - - // Initialize routing history - initRoutingHistory(s.basePath); - - // Restore the model that was active when auto bootstrap began (#650, #2829). - if (startModelSnapshot) { - s.autoModeStartModel = { - provider: startModelSnapshot.provider, - id: startModelSnapshot.id, - }; - } - s.manualSessionModelOverride = manualSessionOverride ?? null; - - // Apply worker model override from parallel orchestrator (#worker-model). - // SF_WORKER_MODEL is injected by the coordinator when parallel.worker_model - // is configured, so parallel milestone workers use a cheaper model than the - // coordinator session (e.g. Haiku for execution, Sonnet for planning). - const workerModelOverride = process.env.SF_WORKER_MODEL; - if (workerModelOverride && process.env.SF_PARALLEL_WORKER === "1") { - const availableModels = ctx.modelRegistry.getAvailable(); - const { resolveModelId } = await import("./auto-model-selection.js"); - const overrideModel = resolveModelId(workerModelOverride, availableModels, ctx.model?.provider); - if (overrideModel) { - const ok = await pi.setModel(overrideModel, { persist: resolvePersistModelChanges() }); - if (ok) { - // Update start model so all subsequent units use this as the baseline - s.autoModeStartModel = { provider: overrideModel.provider, id: overrideModel.id }; - ctx.ui.notify(`Worker model override: ${overrideModel.provider}/${overrideModel.id}`, "info"); - } - } - } - - // Snapshot installed skills - if (resolveSkillDiscoveryMode() !== "off") { - snapshotSkills(); - } - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.setFooter(hideFooter); - // Hide gsd-health during AUTO — gsd-progress is the single source of truth - // for last-commit / cost / health signal while auto is running. - ctx.ui.setWidget("gsd-health", undefined); - const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; - const pendingCount = (state.registry ?? []).filter( - (m) => m.status !== "complete" && m.status !== "parked", - ).length; - const scopeMsg = - pendingCount > 1 - ? `Will loop through ${pendingCount} milestones.` - : "Will loop until milestone complete."; - ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); - - // Show dynamic routing status so users know upfront if models will be - // downgraded for simple tasks (#3962). - // Use the same effective logic as selectAndApplyModel: check flat-rate - // provider suppression and resolve the actual ceiling model. - const routingConfig = resolveDynamicRoutingConfig(); - const startModelLabel = s.autoModeStartModel - ? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}` - : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default"; - - // Flat-rate providers (e.g. GitHub Copilot, claude-code, user-declared - // subscription proxies, externalCli CLIs) suppress routing at dispatch - // time (#3453) — reflect that in the banner. Thread the same - // FlatRateContext used by selectAndApplyModel so user-declared - // flat-rate providers and externalCli auto-detection are respected. - const { isFlatRateProvider, buildFlatRateContext } = await import("./auto-model-selection.js"); - const bannerPrefs = loadEffectiveGSDPreferences()?.preferences; - const effectiveProvider = s.autoModeStartModel?.provider ?? ctx.model?.provider; - const effectivelyEnabled = routingConfig.enabled - && !(effectiveProvider && isFlatRateProvider( - effectiveProvider, - buildFlatRateContext(effectiveProvider, ctx, bannerPrefs), - )); - - // The actual ceiling may come from tier_models.heavy, not the start model. - const effectiveCeiling = (routingConfig.enabled && routingConfig.tier_models?.heavy) - ? routingConfig.tier_models.heavy - : startModelLabel; - - if (effectivelyEnabled) { - ctx.ui.notify( - `Dynamic routing: enabled — simple tasks may use cheaper models (ceiling: ${effectiveCeiling})`, - "info", - ); - } else { - ctx.ui.notify( - `Dynamic routing: disabled — all tasks will use ${startModelLabel}`, - "info", - ); - } - - updateSessionLock( - lockBase(), - "starting", - s.currentMilestoneId ?? "unknown", - ); - writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown"); - - // Secrets collection gate - const mid = state.activeMilestone!.id; - try { - const manifestStatus = await getManifestStatus(base, mid, s.originalBasePath || base); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await collectSecretsFromManifest(base, mid, ctx); - if ( - result && - result.applied && - result.skipped && - result.existingSkipped - ) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } - } - } catch (err) { - ctx.ui.notify( - `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, - "warning", - ); - } - - // Self-heal: remove stale .git/index.lock - try { - const gitLockFile = join(base, ".git", "index.lock"); - if (existsSync(gitLockFile)) { - const lockAge = Date.now() - statSync(gitLockFile).mtimeMs; - if (lockAge > 60_000) { - unlinkSync(gitLockFile); - ctx.ui.notify( - "Removed stale .git/index.lock from prior crash.", - "info", - ); - } - } - } catch (e) { - debugLog("git-lock-cleanup-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - - // Pre-flight: validate milestone queue - try { - const msDir = join(base, ".gsd", "milestones"); - if (existsSync(msDir)) { - const milestoneIds = readdirSync(msDir, { withFileTypes: true }) - .filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name)) - .map((d) => d.name.match(/^(M\d{3})/)?.[1] ?? d.name); - if (milestoneIds.length > 1) { - const issues: string[] = []; - for (const id of milestoneIds) { - // Skip completed/parked milestones — a leftover CONTEXT-DRAFT.md - // on a finished milestone is harmless residue, not an actionable warning. - if (isDbAvailable()) { - const ms = getMilestone(id); - if (ms?.status === "complete" || ms?.status === "parked") continue; - } - const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT"); - if (draft) - issues.push( - `${id}: has CONTEXT-DRAFT.md (will pause for discussion)`, - ); - } - if (issues.length > 0) { - ctx.ui.notify( - `Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map((i) => ` ⚠ ${i}`).join("\n")}`, - "warning", - ); - } else { - ctx.ui.notify( - `Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, - "info", - ); - } - } - } - } catch (err) { - /* non-fatal */ - logWarning("engine", `preflight validation failed: ${err instanceof Error ? err.message : String(err)}`); - } - - return true; - } catch (err) { - releaseSessionLock(base); - clearLock(base); - throw err; - } -} diff --git a/src/resources/extensions/gsd/auto-supervisor.ts b/src/resources/extensions/gsd/auto-supervisor.ts deleted file mode 100644 index 49bfbeca0..000000000 --- a/src/resources/extensions/gsd/auto-supervisor.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Auto-mode Supervisor — signal handling and working-tree activity detection. - * - * Pure functions — no module-level globals or AutoContext dependency. - */ - -import { clearLock } from "./crash-recovery.js"; -import { releaseSessionLock } from "./session-lock.js"; -import { nativeHasChanges } from "./native-git-bridge.js"; - -// ─── Signal Handling ───────────────────────────────────────────────────────── - -/** Signals that should trigger lock cleanup on process termination. */ -const CLEANUP_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGHUP", "SIGINT"]; - -/** Module-level reference to the last registered handler, used as a safety net - * to prevent handler accumulation if the caller neglects to pass previousHandler. */ -let _currentSigtermHandler: (() => void) | null = null; - -/** - * Register signal handlers that clear lock files and exit cleanly. - * Installs handlers on SIGTERM, SIGHUP, and SIGINT so that lock files - * are cleaned up regardless of how the process is terminated (normal kill, - * parent process death, or Ctrl+C). - * - * Captures the active base path at registration time so the handler - * always references the correct path even if the module variable changes. - * Removes any previously registered handler before installing the new one. - * - * Returns the new handler so the caller can store and deregister it later. - */ -export function registerSigtermHandler( - currentBasePath: string, - previousHandler: (() => void) | null, -): () => void { - // Remove the explicitly-passed previous handler - if (previousHandler) { - for (const sig of CLEANUP_SIGNALS) process.off(sig, previousHandler); - } - // Safety net: also remove the module-tracked handler in case the caller - // forgot to pass previousHandler (prevents handler accumulation) - if (_currentSigtermHandler && _currentSigtermHandler !== previousHandler) { - for (const sig of CLEANUP_SIGNALS) process.off(sig, _currentSigtermHandler); - } - const handler = () => { - clearLock(currentBasePath); - releaseSessionLock(currentBasePath); - process.exit(0); - }; - for (const sig of CLEANUP_SIGNALS) process.on(sig, handler); - _currentSigtermHandler = handler; - return handler; -} - -/** Deregister signal handlers from all cleanup signals (called on stop/pause). */ -export function deregisterSigtermHandler(handler: (() => void) | null): void { - if (handler) { - for (const sig of CLEANUP_SIGNALS) process.off(sig, handler); - } - if (_currentSigtermHandler === handler) { - _currentSigtermHandler = null; - } -} - -// ─── Working Tree Activity Detection ────────────────────────────────────────── - -/** - * Detect whether the agent is producing work on disk by checking git for - * any working-tree changes (staged, unstaged, or untracked). Returns true - * if there are uncommitted changes — meaning the agent is actively working, - * even though it hasn't signaled progress through runtime records. - */ -export function detectWorkingTreeActivity(cwd: string): boolean { - try { - return nativeHasChanges(cwd); - } catch { - return false; - } -} diff --git a/src/resources/extensions/gsd/auto-timeout-recovery.ts b/src/resources/extensions/gsd/auto-timeout-recovery.ts deleted file mode 100644 index 28eea6032..000000000 --- a/src/resources/extensions/gsd/auto-timeout-recovery.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Timeout recovery logic for auto-mode units. - * Handles idle and hard timeout recovery with escalation, steering messages, - * and blocker placeholder generation. - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; -import { - readUnitRuntimeRecord, - writeUnitRuntimeRecord, - formatExecuteTaskRecoveryStatus, - inspectExecuteTaskDurability, -} from "./unit-runtime.js"; -import { - resolveExpectedArtifactPath, - diagnoseExpectedArtifact, - writeBlockerPlaceholder, -} from "./auto-recovery.js"; -import { existsSync } from "node:fs"; - -import { resolveAgentEnd } from "./auto-loop.js"; - -export interface RecoveryContext { - basePath: string; - verbose: boolean; - currentUnitStartedAt: number; - unitRecoveryCount: Map; -} - -export async function recoverTimedOutUnit( - ctx: ExtensionContext, - pi: ExtensionAPI, - unitType: string, - unitId: string, - reason: "idle" | "hard", - rctx: RecoveryContext, -): Promise<"recovered" | "paused"> { - const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount } = rctx; - - const runtime = readUnitRuntimeRecord(basePath, unitType, unitId); - const recoveryAttempts = runtime?.recoveryAttempts ?? 0; - const maxRecoveryAttempts = reason === "idle" ? 2 : 1; - - const recoveryKey = `${unitType}/${unitId}`; - const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1; - unitRecoveryCount.set(recoveryKey, attemptNumber); - - if (attemptNumber > 1) { - // Exponential backoff: 2^(n-1) seconds, capped at 30s - const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000); - ctx.ui.notify( - `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`, - "info", - ); - await new Promise(r => setTimeout(r, backoffMs)); - } - - if (unitType === "execute-task") { - const status = await inspectExecuteTaskDurability(basePath, unitId); - if (!status) return "paused"; - - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - recovery: status, - }); - - const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced; - if (durableComplete) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "finalized", - recovery: status, - }); - ctx.ui.notify( - `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`, - "info", - ); - unitRecoveryCount.delete(recoveryKey); - resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); - return "recovered"; - } - - if (recoveryAttempts < maxRecoveryAttempts) { - const isEscalation = recoveryAttempts > 0; - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "recovered", - recovery: status, - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - lastProgressAt: Date.now(), - progressCount: (runtime?.progressCount ?? 0) + 1, - lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry", - }); - - const steeringLines = isEscalation - ? [ - `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`, - `You are still executing ${unitType} ${unitId}.`, - `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`, - `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`, - "You MUST finish the durable output NOW, even if incomplete.", - "Write the task summary with whatever you have accomplished so far.", - "Mark the task [x] in the plan. Commit your work.", - "A partial summary is infinitely better than no summary.", - ] - : [ - `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`, - `You are still executing ${unitType} ${unitId}.`, - `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`, - `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`, - "Do not keep exploring.", - "Immediately finish the required durable output for this unit.", - "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.", - ]; - - pi.sendMessage( - { - customType: "gsd-auto-timeout-recovery", - display: verbose, - content: steeringLines.join("\n"), - }, - { triggerTurn: true, deliverAs: "steer" }, - ); - ctx.ui.notify( - `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`, - "warning", - ); - return "recovered"; - } - - // Retries exhausted — write a blocker placeholder and advance. - const diagnostic = formatExecuteTaskRecoveryStatus(status); - const placeholder = writeBlockerPlaceholder( - unitType, unitId, basePath, - `${reason} recovery exhausted ${maxRecoveryAttempts} attempts. Status: ${diagnostic}`, - ); - - if (placeholder) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "skipped", - recovery: status, - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - ctx.ui.notify( - `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`, - "warning", - ); - unitRecoveryCount.delete(recoveryKey); - resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); - return "recovered"; - } - - // Fallback: couldn't write skip artifacts — pause as before. - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "paused", - recovery: status, - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - ctx.ui.notify( - `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`, - "warning", - ); - return "paused"; - } - - const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact"; - - // Check if the artifact already exists on disk — agent may have written it - // without signaling completion. - const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath); - if (artifactPath && existsSync(artifactPath)) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "finalized", - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - ctx.ui.notify( - `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`, - "info", - ); - unitRecoveryCount.delete(recoveryKey); - resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); - return "recovered"; - } - - if (recoveryAttempts < maxRecoveryAttempts) { - const isEscalation = recoveryAttempts > 0; - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "recovered", - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - lastProgressAt: Date.now(), - progressCount: (runtime?.progressCount ?? 0) + 1, - lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry", - }); - - const steeringLines = isEscalation - ? [ - `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`, - `You are still executing ${unitType} ${unitId}.`, - `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`, - `Expected durable output: ${expected}.`, - "You MUST write the artifact file NOW, even if incomplete.", - "Write whatever you have — partial research, preliminary findings, best-effort analysis.", - "A partial artifact is infinitely better than no artifact.", - "If you are truly blocked, write the file with a BLOCKER section explaining why.", - ] - : [ - `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`, - `You are still executing ${unitType} ${unitId}.`, - `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`, - `Expected durable output: ${expected}.`, - "Stop broad exploration.", - "Write the required artifact now.", - "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.", - ]; - - pi.sendMessage( - { - customType: "gsd-auto-timeout-recovery", - display: verbose, - content: steeringLines.join("\n"), - }, - { triggerTurn: true, deliverAs: "steer" }, - ); - ctx.ui.notify( - `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`, - "warning", - ); - return "recovered"; - } - - // #4175: For complete-milestone, never write a blocker placeholder — a stub - // SUMMARY has no recovery value (milestone is terminal), it does not update - // DB status, and downstream merge paths can treat the stub as a legitimate - // completion signal. Pause instead so the worktree branch is preserved. - if (unitType === "complete-milestone") { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "paused", - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - ctx.ui.notify( - `Milestone ${unitId} ${reason}-recovery exhausted ${maxRecoveryAttempts} attempt(s) — worktree branch preserved. Re-run /gsd auto once blockers are resolved.`, - "error", - ); - return "paused"; - } - - // Retries exhausted — write a blocker placeholder and advance the pipeline - // instead of silently stalling. - const placeholder = writeBlockerPlaceholder( - unitType, unitId, basePath, - `${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`, - ); - - if (placeholder) { - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "skipped", - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - ctx.ui.notify( - `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`, - "warning", - ); - unitRecoveryCount.delete(recoveryKey); - resolveAgentEnd({ messages: [], _synthetic: "timeout-recovery" } as any); - return "recovered"; - } - - // Fallback: couldn't resolve artifact path — pause as before. - writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, { - phase: "paused", - recoveryAttempts: recoveryAttempts + 1, - lastRecoveryReason: reason, - }); - return "paused"; -} diff --git a/src/resources/extensions/gsd/auto-timers.ts b/src/resources/extensions/gsd/auto-timers.ts deleted file mode 100644 index 2324071a6..000000000 --- a/src/resources/extensions/gsd/auto-timers.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * Unit supervision timers — soft timeout warning, idle watchdog, - * hard timeout, and context-pressure monitor. - * - * Originally extracted from dispatchNextUnit() in auto.ts (now deleted — replaced by autoLoop). - * via startUnitSupervision() and torn down by the caller via clearUnitTimeout(). - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; -import { readUnitRuntimeRecord, writeUnitRuntimeRecord } from "./unit-runtime.js"; -import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; -import { resolveAutoSupervisorConfig } from "./preferences.js"; -import type { GSDPreferences } from "./preferences.js"; -import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js"; -import { - getInFlightToolCount, - getOldestInFlightToolStart, - clearInFlightTools, - hasInteractiveToolInFlight, -} from "./auto-tool-tracking.js"; -import { detectWorkingTreeActivity } from "./auto-supervisor.js"; -import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; -import { saveActivityLog } from "./activity-log.js"; -import { recoverTimedOutUnit, type RecoveryContext } from "./auto-timeout-recovery.js"; -import { resolveAgentEndCancelled } from "./auto/resolve.js"; -import type { AutoSession } from "./auto/session.js"; -import { logWarning, logError } from "./workflow-logger.js"; - -export interface SupervisionContext { - s: AutoSession; - ctx: ExtensionContext; - pi: ExtensionAPI; - unitType: string; - unitId: string; - prefs: GSDPreferences | undefined; - buildSnapshotOpts: () => CloseoutOptions & Record; - buildRecoveryContext: () => RecoveryContext; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - /** Optional task estimate string (e.g. "30m", "2h") for timeout scaling (#2243). */ - taskEstimate?: string; -} - -/** - * Set up all four supervision timers for the current unit: - * 1. Soft timeout warning (wrapup) - * 2. Idle watchdog (progress polling, stuck tool detection) - * 3. Hard timeout (pause + recovery) - * 4. Context-pressure monitor (continue-here) - */ - -/** - * Parse a task estimate string (e.g. "30m", "2h", "1h30m") into minutes. - * Returns null if the string cannot be parsed. - */ -export function parseEstimateMinutes(estimate: string): number | null { - if (!estimate || typeof estimate !== "string") return null; - const trimmed = estimate.trim(); - if (!trimmed) return null; - - let totalMinutes = 0; - let matched = false; - - // Match hours component - const hoursMatch = trimmed.match(/(\d+)\s*h/i); - if (hoursMatch) { - totalMinutes += Number(hoursMatch[1]) * 60; - matched = true; - } - - // Match minutes component - const minutesMatch = trimmed.match(/(\d+)\s*m/i); - if (minutesMatch) { - totalMinutes += Number(minutesMatch[1]); - matched = true; - } - - return matched ? totalMinutes : null; -} - -export function startUnitSupervision(sctx: SupervisionContext): void { - const { s, ctx, pi, unitType, unitId, prefs, buildSnapshotOpts, buildRecoveryContext, pauseAuto } = sctx; - - const supervisor = resolveAutoSupervisorConfig(); - - // Scale timeouts based on task estimate annotations (#2243). - // If the task has an est: annotation, use it to extend the hard and soft timeouts - // so longer tasks don't get prematurely timed out. - let taskEstimate = sctx.taskEstimate; - if (!taskEstimate && unitType === "task" && isDbAvailable()) { - // Look up the task estimate from the DB (#2243). - try { - if (s.currentMilestoneId) { - const slices = getMilestoneSlices(s.currentMilestoneId); - for (const slice of slices) { - const tasks = getSliceTasks(s.currentMilestoneId, slice.id); - const task = tasks.find(t => t.id === unitId); - if (task?.estimate) { - taskEstimate = task.estimate; - break; - } - } - } - } catch (err) { - // Non-fatal — fall through with no estimate - logWarning("timer", `operation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - const estimateMinutes = taskEstimate ? parseEstimateMinutes(taskEstimate) : null; - const MAX_TIMEOUT_SCALE = 6; // Cap at 6x (60min task). Prevents 2h+ tasks from creating 120min+ timeout windows. - const timeoutScale = estimateMinutes && estimateMinutes > 0 - ? Math.min(MAX_TIMEOUT_SCALE, Math.max(1, estimateMinutes / 10)) - : 1; - - const softTimeoutMs = (supervisor.soft_timeout_minutes ?? 0) * 60 * 1000 * timeoutScale; - const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000; // idle not scaled — idle is idle - const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000 * timeoutScale; - - // ── 1. Soft timeout warning ── - s.wrapupWarningHandle = setTimeout(() => { - s.wrapupWarningHandle = null; - if (!s.active || !s.currentUnit) return; - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "wrapup-warning-sent", - wrapupWarningSent: true, - }); - // Only trigger a new turn if no tools are currently in flight. - // Triggering during active tool calls causes tool results to be skipped - // with "Skipped due to queued user message", leading to provider errors (#3512). - const softTrigger = getInFlightToolCount() === 0; - pi.sendMessage( - { - customType: "gsd-auto-wrapup", - display: s.verbose, - content: [ - "**TIME BUDGET WARNING — keep going only if progress is real.**", - "This unit crossed the soft time budget.", - "If you are making progress, continue. If not, switch to wrap-up mode now:", - "1. rerun the minimal required verification", - "2. write or update the required durable artifacts", - "3. mark task or slice state on disk correctly", - "4. leave precise resume notes if anything remains unfinished", - ].join("\n"), - }, - { triggerTurn: softTrigger }, - ); - }, softTimeoutMs); - - // ── 2. Idle watchdog ── - s.idleWatchdogHandle = setInterval(async () => { - try { - if (!s.active || !s.currentUnit) return; - const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); - if (!runtime) return; - if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; - - // Agent has tool calls currently executing — not idle, just waiting. - // But only suppress recovery if the tool started recently. - let stalledToolDetected = false; - if (getInFlightToolCount() > 0) { - // User-interactive tools (ask_user_questions, secure_env_collect) block - // waiting for human input by design — never treat them as stalled (#2676). - if (hasInteractiveToolInFlight()) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - lastProgressAt: Date.now(), - lastProgressKind: "interactive-tool-waiting", - }); - return; - } - const oldestStart = getOldestInFlightToolStart()!; - const toolAgeMs = Date.now() - oldestStart; - if (toolAgeMs < idleTimeoutMs) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - lastProgressAt: Date.now(), - lastProgressKind: "tool-in-flight", - }); - return; - } - // Tool has been in-flight longer than idle timeout — treat as hung. - // Clear the stale entries so subsequent ticks don't re-detect them, - // and set the flag so the filesystem-activity check below does not - // override the stall verdict (#2527). - stalledToolDetected = true; - clearInFlightTools(); - ctx.ui.notify( - `Stalled tool detected: a tool has been in-flight for ${Math.round(toolAgeMs / 60000)}min. Treating as hung — attempting idle recovery.`, - "warning", - ); - } - - // Check if the agent is producing work on disk. - // Skip this when a stalled tool was just detected — filesystem changes - // from earlier in the task should not override the stall verdict (#2527). - if (!stalledToolDetected && detectWorkingTreeActivity(s.basePath)) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - lastProgressAt: Date.now(), - lastProgressKind: "filesystem-activity", - }); - return; - } - - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext()); - if (recovery === "recovered") return; - - // Guard: recoverTimedOutUnit is async — pauseAuto/stopAuto may have - // set s.currentUnit = null during the await (#2527). - if (!s.currentUnit) return; - - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "paused", - }); - ctx.ui.notify( - `Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logError("timer", `[idle-watchdog] Unhandled error: ${message}`); - // Unblock any pending unit promise so the auto-loop is not orphaned. - resolveAgentEndCancelled({ message: `Idle watchdog error: ${message}`, category: "idle", isTransient: true }); - try { - ctx.ui.notify(`Idle watchdog error: ${message}`, "warning"); - } catch (err) { /* best effort */ - logWarning("timer", `notification failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - }, 15000); - - // ── 3. Hard timeout ── - s.unitTimeoutHandle = setTimeout(async () => { - try { - s.unitTimeoutHandle = null; - if (!s.active) return; - if (s.currentUnit) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "timeout", - timeoutAt: Date.now(), - }); - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext()); - if (recovery === "recovered") return; - - ctx.ui.notify( - `Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logError("timer", `[hard-timeout] Unhandled error: ${message}`); - // Unblock any pending unit promise so the auto-loop is not orphaned. - resolveAgentEndCancelled({ message: `Hard timeout error: ${message}`, category: "timeout", isTransient: true }); - try { - ctx.ui.notify(`Hard timeout error: ${message}`, "warning"); - } catch (err) { /* best effort */ - logWarning("timer", `notification failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - }, hardTimeoutMs); - - // ── 4. Context-pressure continue-here monitor ── - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - const executorContextWindow = resolveExecutorContextWindow( - ctx.modelRegistry as Parameters[0], - prefs as Parameters[1], - ctx.model?.contextWindow, - ); - const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent; - s.continueHereHandle = setInterval(() => { - if (!s.active || !s.currentUnit || !s.cmdCtx) return; - const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); - if (runtime?.continueHereFired) return; - - const contextUsage = s.cmdCtx.getContextUsage(); - if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return; - - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, { - continueHereFired: true, - }); - - if (s.verbose) { - ctx.ui.notify( - `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, - "info", - ); - } - - // Only trigger a new turn if no tools are currently in flight (#3512). - const contextTrigger = getInFlightToolCount() === 0; - pi.sendMessage( - { - customType: "gsd-auto-wrapup", - display: s.verbose, - content: [ - "**CONTEXT BUDGET WARNING — wrap up this unit now.**", - `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`, - "The next unit needs a fresh context to work effectively. Wrap up now:", - "1. Finish any in-progress file writes", - "2. Write or update the required durable artifacts (summary, checkboxes)", - "3. Mark task state on disk correctly", - "4. Leave precise resume notes if anything remains unfinished", - "Do NOT start new sub-tasks or investigations.", - ].join("\n"), - }, - { triggerTurn: contextTrigger }, - ); - - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - }, 15_000); -} - diff --git a/src/resources/extensions/gsd/auto-tool-tracking.ts b/src/resources/extensions/gsd/auto-tool-tracking.ts deleted file mode 100644 index ffe54cdd4..000000000 --- a/src/resources/extensions/gsd/auto-tool-tracking.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * In-flight tool call tracking for auto-mode idle detection. - * Tracks which tool calls are currently executing so the idle watchdog - * can distinguish "waiting for tool completion" from "truly idle". - */ - -interface InFlightTool { - startedAt: number; - toolName: string; -} - -const inFlightTools = new Map(); - -/** - * Tools that block waiting for human input by design. - * The idle watchdog must not treat these as stalled. - */ -const INTERACTIVE_TOOLS = new Set(["ask_user_questions", "secure_env_collect"]); - -/** - * Mark a tool execution as in-flight. - * Records start time and tool name so the idle watchdog can detect tools - * hung longer than the idle timeout while exempting interactive tools. - */ -export function markToolStart(toolCallId: string, isActive: boolean, toolName?: string): void { - if (!isActive) return; - inFlightTools.set(toolCallId, { startedAt: Date.now(), toolName: toolName ?? "unknown" }); -} - -/** - * Mark a tool execution as completed. - */ -export function markToolEnd(toolCallId: string): void { - inFlightTools.delete(toolCallId); -} - -/** - * Returns the age (ms) of the oldest currently in-flight tool, or 0 if none. - */ -export function getOldestInFlightToolAgeMs(): number { - if (inFlightTools.size === 0) return 0; - let oldestStart = Infinity; - for (const t of inFlightTools.values()) { - if (t.startedAt < oldestStart) oldestStart = t.startedAt; - } - return Date.now() - oldestStart; -} - -/** - * Returns the number of currently in-flight tools. - */ -export function getInFlightToolCount(): number { - return inFlightTools.size; -} - -/** - * Returns the start timestamp of the oldest in-flight tool, or undefined if none. - */ -export function getOldestInFlightToolStart(): number | undefined { - if (inFlightTools.size === 0) return undefined; - let oldest = Infinity; - for (const t of inFlightTools.values()) { - if (t.startedAt < oldest) oldest = t.startedAt; - } - return oldest; -} - -/** - * Returns true if any currently in-flight tool is a user-interactive tool - * (e.g. ask_user_questions, secure_env_collect) that blocks waiting for - * human input. These must be exempt from idle stall detection. - */ -export function hasInteractiveToolInFlight(): boolean { - for (const { toolName } of inFlightTools.values()) { - if (INTERACTIVE_TOOLS.has(toolName)) return true; - } - return false; -} - -/** - * Clear all in-flight tool tracking state. - */ -export function clearInFlightTools(): void { - inFlightTools.clear(); -} - -const MAX_TOP_TOOLS_IN_SUMMARY = 5; -const toolCallCountsByName = new Map(); - -export function resetToolCallCounts(): void { - toolCallCountsByName.clear(); -} - -export function recordToolCallName(toolName: string): void { - if (!toolName) return; - toolCallCountsByName.set(toolName, (toolCallCountsByName.get(toolName) ?? 0) + 1); -} - -export function formatToolCallSummary(): string | null { - if (toolCallCountsByName.size === 0) return null; - let total = 0; - for (const count of toolCallCountsByName.values()) total += count; - const ranked = [...toolCallCountsByName.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, MAX_TOP_TOOLS_IN_SUMMARY) - .map(([name, count]) => `${name}×${count}`); - return `${total} calls (top-${ranked.length}: ${ranked.join(", ")})`; -} - -// ─── Tool invocation error classification (#2883) ──────────────────────── - -/** - * Patterns that indicate a tool invocation failed due to malformed or truncated - * JSON arguments — as opposed to a normal business-logic error from the tool - * handler. When these errors occur, retrying the same unit will produce the same - * failure, so the retry loop must be broken. - */ -const TOOL_INVOCATION_ERROR_RE = /Validation failed for tool|Expected ',' or '\}'(?: after property value)?(?: in JSON)?|Unexpected end of JSON|Unexpected token.*in JSON/i; - -/** - * Returns true if the error message indicates a tool invocation failure due to - * malformed/truncated arguments (as opposed to a normal tool execution error). - */ -export function isToolInvocationError(errorMsg: string): boolean { - if (!errorMsg) return false; - return TOOL_INVOCATION_ERROR_RE.test(errorMsg); -} - -/** - * Returns true if the error message indicates the tool was skipped because - * a queued user message interrupted the turn (#3595). Retrying will produce - * the same skip, so the unit should be paused rather than retried. - */ -export function isQueuedUserMessageSkip(errorMsg: string): boolean { - if (!errorMsg) return false; - return /^Skipped due to queued user message\.?$/i.test(errorMsg.trim()); -} diff --git a/src/resources/extensions/gsd/auto-unit-closeout.ts b/src/resources/extensions/gsd/auto-unit-closeout.ts deleted file mode 100644 index 5e54480a9..000000000 --- a/src/resources/extensions/gsd/auto-unit-closeout.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Unit closeout helper — consolidates the repeated pattern of - * snapshotting metrics + saving activity log + extracting memories - * that appears 6+ times in auto.ts. - */ - -import type { ExtensionContext } from "@sf-run/pi-coding-agent"; -import { snapshotUnitMetrics } from "./metrics.js"; -import { saveActivityLog } from "./activity-log.js"; -import { logWarning } from "./workflow-logger.js"; -import { writeTurnGitTransaction } from "./uok/gitops.js"; - -export interface CloseoutOptions { - promptCharCount?: number; - baselineCharCount?: number; - tier?: string; - modelDowngraded?: boolean; - continueHereFired?: boolean; - traceId?: string; - turnId?: string; - gitAction?: "commit" | "snapshot" | "status-only"; - gitPush?: boolean; - gitStatus?: "ok" | "failed"; - gitError?: string; -} - -/** - * Snapshot metrics, save activity log, and fire-and-forget memory extraction - * for a completed unit. Returns the activity log file path (if any). - */ -export async function closeoutUnit( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, - startedAt: number, - opts?: CloseoutOptions, -): Promise { - const modelId = ctx.model?.id ?? "unknown"; - snapshotUnitMetrics(ctx, unitType, unitId, startedAt, modelId, opts); - const activityFile = saveActivityLog(ctx, basePath, unitType, unitId); - - if (activityFile) { - try { - const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js'); - const llmCallFn = buildMemoryLLMCall(ctx); - if (llmCallFn) { - extractMemoriesFromUnit(activityFile, unitType, unitId, llmCallFn).catch((err) => { - logWarning("engine", `memory extraction failed for ${unitType}/${unitId}: ${(err as Error).message}`); - }); - } - } catch (err) { /* non-fatal */ - logWarning("engine", `operation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - if (opts?.traceId && opts.turnId && opts.gitAction && opts.gitStatus) { - writeTurnGitTransaction({ - basePath, - traceId: opts.traceId, - turnId: opts.turnId, - unitType, - unitId, - stage: "record", - action: opts.gitAction, - push: opts.gitPush === true, - status: opts.gitStatus, - error: opts.gitError, - metadata: { - activityFile, - }, - }); - } - - return activityFile ?? undefined; -} diff --git a/src/resources/extensions/gsd/auto-utils.ts b/src/resources/extensions/gsd/auto-utils.ts deleted file mode 100644 index ec8b23c6f..000000000 --- a/src/resources/extensions/gsd/auto-utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Shared utilities for the auto-loop modules (auto-post-unit, auto, etc.). - -import { debugLog } from "./debug-logger.js"; - -/** - * Run a non-fatal operation, logging any error via `debugLog` and continuing. - * - * Replaces the repeated try-catch-debugLog-continue boilerplate that wraps - * operations whose failure should not abort the post-unit pipeline. - * - * @param context - The debugLog event name (e.g. "postUnit") - * @param phase - The phase label attached to the debug entry - * @param fn - The operation to execute (may be sync or async) - */ -export async function runSafely( - context: string, - phase: string, - fn: () => Promise | void, -): Promise { - try { - await fn(); - } catch (e) { - debugLog(context, { phase, error: String(e) }); - } -} diff --git a/src/resources/extensions/gsd/auto-verification.ts b/src/resources/extensions/gsd/auto-verification.ts deleted file mode 100644 index 09182bb88..000000000 --- a/src/resources/extensions/gsd/auto-verification.ts +++ /dev/null @@ -1,650 +0,0 @@ -/** - * Post-unit verification gate for auto-mode. - * - * Runs typecheck/lint/test checks, captures runtime errors, performs - * dependency audits, handles auto-fix retry logic, and writes - * verification evidence JSON. - * - * Extracted from handleAgentEnd() in auto.ts. Returns a sentinel - * value instead of calling return/pauseAuto directly — the caller - * checks the result and handles control flow. - */ - -import type { ExtensionContext, ExtensionAPI } from "@sf-run/pi-coding-agent"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolveSliceFile, resolveSlicePath, resolveMilestoneFile } from "./paths.js"; -import { parseUnitId } from "./unit-id.js"; -import { isDbAvailable, getTask, getSliceTasks, getMilestoneSlices, type TaskRow } from "./gsd-db.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { extractVerdict } from "./verdict-parser.js"; -import { isClosedStatus } from "./status-guards.js"; -import { loadFile } from "./files.js"; -import { parseRoadmap } from "./parsers-legacy.js"; -import { isMilestoneComplete } from "./state.js"; -import { - runVerificationGate, - formatFailureContext, - captureRuntimeErrors, - runDependencyAudit, -} from "./verification-gate.js"; -import { writeVerificationJSON, type PostExecutionCheckJSON, type EvidenceJSON } from "./verification-evidence.js"; -import { logWarning } from "./workflow-logger.js"; -import { runPostExecutionChecks, type PostExecutionResult } from "./post-execution-checks.js"; -import type { AutoSession } from "./auto/session.js"; -import type { VerificationResult as VerificationGateResult } from "./types.js"; -import { join } from "node:path"; -import { resolveUokFlags } from "./uok/flags.js"; -import { UokGateRunner } from "./uok/gate-runner.js"; - -export interface VerificationContext { - s: AutoSession; - ctx: ExtensionContext; - pi: ExtensionAPI; -} - -export type VerificationResult = "continue" | "retry" | "pause"; - -function isInfraVerificationFailure(stderr: string): boolean { - return /\b(ENOENT|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|spawn\s+\S+\s+ENOENT|command not found)\b/i.test( - stderr, - ); -} - -/** - * Post-unit guard for `validate-milestone` units (#4094). - * - * When validate-milestone writes verdict=needs-remediation, the agent is - * expected to also call gsd_reassess_roadmap in the same turn to add - * remediation slices. If they don't, the state machine re-derives - * `phase: validating-milestone` indefinitely (all slices still complete + - * verdict still needs-remediation), wasting ~3 dispatches before the stuck - * detector fires. - * - * This guard fires immediately on the first occurrence: if VALIDATION.md - * verdict is needs-remediation and no incomplete slices exist for the - * milestone, pause the auto-loop with a clear blocker. - */ -async function runValidateMilestonePostCheck( - vctx: VerificationContext, - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, -): Promise { - const { s, ctx, pi } = vctx; - const prefs = loadEffectiveGSDPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - const persistMilestoneValidationGate = async ( - outcome: "pass" | "fail" | "retry" | "manual-attention", - failureClass: "none" | "verification" | "manual-attention", - rationale: string, - findings = "", - milestoneId?: string, - ): Promise => { - if (!uokFlags.gates || !s.currentUnit) return; - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "milestone-validation-post-check", - type: "verification", - execute: async () => ({ - outcome, - failureClass, - rationale, - findings, - }), - }); - await gateRunner.run("milestone-validation-post-check", { - basePath: s.basePath, - traceId: `validation-post-check:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - }; - - if (!s.currentUnit) return "continue"; - - const { milestone: mid } = parseUnitId(s.currentUnit.id); - if (!mid) return "continue"; - - const validationFile = resolveMilestoneFile(s.basePath, mid, "VALIDATION"); - if (!validationFile) return "continue"; - - const validationContent = await loadFile(validationFile); - if (!validationContent) return "continue"; - - const verdict = extractVerdict(validationContent); - if (verdict !== "needs-remediation") { - await persistMilestoneValidationGate( - "pass", - "none", - `milestone validation verdict is ${verdict}; no remediation loop risk`, - "", - mid, - ); - return "continue"; - } - - const incompleteSliceCount = await countIncompleteSlices(s.basePath, mid); - - // If any non-closed slices exist, the agent successfully queued remediation - // work — proceed normally. The state machine will execute those slices and - // re-validate per the #3596/#3670 fix. - if (incompleteSliceCount > 0) { - await persistMilestoneValidationGate( - "pass", - "none", - `remediation slices present (${incompleteSliceCount}); validation can continue`, - "", - mid, - ); - return "continue"; - } - - ctx.ui.notify( - `Milestone ${mid} validation returned verdict=needs-remediation but no remediation slices were added. Pausing for human review.`, - "error", - ); - process.stderr.write( - `validate-milestone: pausing — verdict=needs-remediation with no incomplete slices for ${mid}. ` + - `The agent must call gsd_reassess_roadmap to add remediation slices before re-validation.\n`, - ); - await persistMilestoneValidationGate( - "manual-attention", - "manual-attention", - "needs-remediation verdict without queued remediation slices", - `No incomplete slices found for ${mid} while verdict=needs-remediation`, - mid, - ); - await pauseAuto(ctx, pi); - return "pause"; -} - -/** - * Count slices for a milestone that are not in a closed status. - * DB-backed projects are authoritative (#4094 peer review); falls back to - * roadmap parsing only when the DB is unavailable. - */ -async function countIncompleteSlices(basePath: string, milestoneId: string): Promise { - if (isDbAvailable()) { - const slices = getMilestoneSlices(milestoneId); - if (slices.length === 0) { - // No DB rows — treat as "unknown", do not pause. - return 1; - } - return slices.filter((slice) => !isClosedStatus(slice.status)).length; - } - - // Filesystem fallback: parse the roadmap markdown. - try { - const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - if (!roadmapFile) return 1; - const roadmapContent = await loadFile(roadmapFile); - if (!roadmapContent) return 1; - const roadmap = parseRoadmap(roadmapContent); - if (roadmap.slices.length === 0) return 1; - return isMilestoneComplete(roadmap) ? 0 : 1; - } catch { - // Parsing failures should not cause false-positive pauses. - return 1; - } -} - -/** - * Run the verification gate for the current execute-task unit. - * Returns: - * - "continue" — gate passed (or no checks configured), proceed normally - * - "retry" — gate failed with retries remaining, s.pendingVerificationRetry set for loop re-iteration - * - "pause" — gate failed with retries exhausted, pauseAuto already called - */ -export async function runPostUnitVerification( - vctx: VerificationContext, - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, -): Promise { - const { s, ctx, pi } = vctx; - - if (!s.currentUnit) { - return "continue"; - } - - if (s.currentUnit.type === "validate-milestone") { - return await runValidateMilestonePostCheck(vctx, pauseAuto); - } - - if (s.currentUnit.type !== "execute-task") { - return "continue"; - } - - try { - const effectivePrefs = loadEffectiveGSDPreferences(); - const prefs = effectivePrefs?.preferences; - const uokFlags = resolveUokFlags(prefs); - - // Read task plan verify field - const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); - let taskPlanVerify: string | undefined; - if (mid && sid && tid) { - if (isDbAvailable()) { - taskPlanVerify = getTask(mid, sid, tid)?.verify; - } - // When DB unavailable, taskPlanVerify stays undefined — gate runs without task-specific checks - } - - const result = runVerificationGate({ - cwd: s.basePath, - preferenceCommands: prefs?.verification_commands, - taskPlanVerify, - }); - - // Capture runtime errors - const runtimeErrors = await captureRuntimeErrors(); - if (runtimeErrors.length > 0) { - result.runtimeErrors = runtimeErrors; - if (runtimeErrors.some((e) => e.blocking)) { - result.passed = false; - } - } - - // Dependency audit - const auditWarnings = runDependencyAudit(s.basePath); - if (auditWarnings.length > 0) { - result.auditWarnings = auditWarnings; - process.stderr.write( - `verification-gate: ${auditWarnings.length} audit warning(s)\n`, - ); - for (const w of auditWarnings) { - process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`); - } - } - - if (uokFlags.gates) { - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "verification-gate", - type: "verification", - execute: async () => ({ - outcome: result.passed ? "pass" : "fail", - failureClass: result.runtimeErrors?.some((e) => e.blocking) - ? "execution" - : "verification", - rationale: result.passed - ? "verification checks passed" - : "verification checks failed", - findings: result.passed - ? "" - : formatFailureContext(result), - }), - }); - - await gateRunner.run("verification-gate", { - basePath: s.basePath, - traceId: `verification:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid ?? undefined, - sliceId: sid ?? undefined, - taskId: tid ?? undefined, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - } - - // Auto-fix retry preferences - const autoFixEnabled = prefs?.verification_auto_fix !== false; - const maxRetries = - typeof prefs?.verification_max_retries === "number" - ? prefs.verification_max_retries - : 2; - - if (result.checks.length > 0) { - const passCount = result.checks.filter((c) => c.exitCode === 0).length; - const total = result.checks.length; - const commandList = result.checks.map((c) => c.command).join(" | "); - ctx.ui.notify(`[verify] running: ${commandList}`, "info"); - const attemptSoFar = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; - if (result.passed) { - ctx.ui.notify(`[verify] PASS - ${passCount}/${total} checks`, "info"); - } else { - const failures = result.checks.filter((c) => c.exitCode !== 0); - const failNames = failures.map((f) => f.command).join(", "); - const nextAttempt = attemptSoFar + 1; - ctx.ui.notify( - `[verify] FAIL - ${failNames} (auto-fix attempt ${nextAttempt}/${maxRetries})`, - "info", - ); - process.stderr.write( - `verification-gate: ${total - passCount}/${total} checks failed\n`, - ); - for (const f of failures) { - process.stderr.write(` ${f.command} exited ${f.exitCode}\n`); - if (f.stderr) - process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`); - } - } - } - - // Log blocking runtime errors - if (result.runtimeErrors?.some((e) => e.blocking)) { - const blockingErrors = result.runtimeErrors.filter((e) => e.blocking); - process.stderr.write( - `verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`, - ); - for (const err of blockingErrors) { - process.stderr.write( - ` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`, - ); - } - } - - // Write verification evidence JSON - const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; - if (mid && sid && tid) { - try { - const sDir = resolveSlicePath(s.basePath, mid, sid); - if (sDir) { - const tasksDir = join(sDir, "tasks"); - if (result.passed) { - writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id); - } else { - const nextAttempt = attempt + 1; - writeVerificationJSON( - result, - tasksDir, - tid, - s.currentUnit.id, - nextAttempt, - maxRetries, - ); - } - } - } catch (evidenceErr) { - logWarning("engine", `verification-evidence write error: ${(evidenceErr as Error).message}`); - } - } - - const advisoryFailure = - !result.passed && - (result.discoverySource === "package-json" || - result.checks.some((check) => - isInfraVerificationFailure(check.stderr), - )); - - if (advisoryFailure) { - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - ctx.ui.notify( - result.discoverySource === "package-json" - ? "Verification failed in auto-discovered package.json checks — treating as advisory." - : "Verification failed due to infrastructure/runtime environment issues — treating as advisory.", - "warning", - ); - return "continue"; - } - - // ── Post-execution checks (run after main verification passes for execute-task units) ── - let postExecChecks: PostExecutionCheckJSON[] | undefined; - let postExecBlockingFailure = false; - - if (result.passed && mid && sid && tid) { - // Check preferences — respect enhanced_verification and enhanced_verification_post - const enhancedEnabled = prefs?.enhanced_verification !== false; // default true - const postEnabled = prefs?.enhanced_verification_post !== false; // default true - - if (enhancedEnabled && postEnabled && isDbAvailable()) { - try { - // Get the completed task from DB - const taskRow = getTask(mid, sid, tid); - if (taskRow && taskRow.key_files && taskRow.key_files.length > 0) { - // Get all tasks in the slice - const allTasks = getSliceTasks(mid, sid); - // Filter to prior completed tasks (status = 'complete' or 'done', before current task) - const priorTasks = allTasks.filter( - (t: TaskRow) => - (t.status === "complete" || t.status === "done") && - t.id !== tid && - t.sequence < taskRow.sequence - ); - - // Run post-execution checks - const postExecResult: PostExecutionResult = runPostExecutionChecks( - taskRow, - priorTasks, - s.basePath - ); - - // Store checks for evidence JSON - postExecChecks = postExecResult.checks; - - // Log summary to stderr with gsd-post-exec: prefix - const emoji = - postExecResult.status === "pass" - ? "✅" - : postExecResult.status === "warn" - ? "⚠️" - : "❌"; - process.stderr.write( - `gsd-post-exec: ${emoji} Post-execution checks ${postExecResult.status} for ${mid}/${sid}/${tid} (${postExecResult.durationMs}ms)\n` - ); - - // Log individual check results - for (const check of postExecResult.checks) { - const checkEmoji = check.passed - ? "✓" - : check.blocking - ? "✗" - : "⚠"; - process.stderr.write( - `gsd-post-exec: ${checkEmoji} [${check.category}] ${check.target}: ${check.message}\n` - ); - } - - if (uokFlags.gates) { - const strictMode = prefs?.enhanced_verification_strict === true; - const warnEscalated = postExecResult.status === "warn" && strictMode; - const blockingFailure = postExecResult.status === "fail" || warnEscalated; - const findings = postExecResult.checks - .filter((check) => !check.passed) - .map((check) => `[${check.category}] ${check.target}: ${check.message}`) - .join("\n"); - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: "post-execution-checks", - type: "artifact", - execute: async () => ({ - outcome: blockingFailure ? "fail" : "pass", - failureClass: postExecResult.status === "fail" - ? "artifact" - : warnEscalated - ? "policy" - : "none", - rationale: blockingFailure - ? `post-execution checks ${postExecResult.status}${warnEscalated ? " (strict)" : ""}` - : "post-execution checks passed", - findings, - }), - }); - await gateRunner.run("post-execution-checks", { - basePath: s.basePath, - traceId: `verification:${s.currentUnit.id}`, - turnId: s.currentUnit.id, - milestoneId: mid, - sliceId: sid, - taskId: tid, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, - }); - } - - // Check for blocking failures - if (postExecResult.status === "fail") { - postExecBlockingFailure = true; - const blockingCount = postExecResult.checks.filter( - (c) => !c.passed && c.blocking - ).length; - ctx.ui.notify( - `Post-execution checks failed: ${blockingCount} blocking issue${blockingCount === 1 ? "" : "s"} found`, - "error" - ); - } else if (postExecResult.status === "warn") { - ctx.ui.notify( - `Post-execution checks passed with warnings`, - "warning" - ); - // Strict mode: treat warnings as blocking - if (prefs?.enhanced_verification_strict === true) { - postExecBlockingFailure = true; - } - } - } - } catch (postExecErr) { - // Post-execution check errors are non-fatal — log and continue - logWarning("engine", `gsd-post-exec: error — ${(postExecErr as Error).message}`); - } - } - } - - // Re-write verification evidence JSON with post-execution checks - if (postExecChecks && postExecChecks.length > 0 && mid && sid && tid) { - try { - const sDir = resolveSlicePath(s.basePath, mid, sid); - if (sDir) { - const tasksDir = join(sDir, "tasks"); - // Add postExecutionChecks to the result for the JSON write - const resultWithPostExec = { - ...result, - // Mark as failed if there was a blocking post-exec failure - passed: result.passed && !postExecBlockingFailure, - }; - // Manually write with postExecutionChecks field - writeVerificationJSONWithPostExec( - resultWithPostExec, - tasksDir, - tid, - s.currentUnit.id, - postExecChecks, - postExecBlockingFailure ? attempt + 1 : undefined, - postExecBlockingFailure ? maxRetries : undefined - ); - } - } catch (evidenceErr) { - logWarning("engine", `verification-evidence: post-exec write error — ${(evidenceErr as Error).message}`); - } - } - - // Update result.passed based on post-execution checks - if (postExecBlockingFailure) { - result.passed = false; - } - - // ── Auto-fix retry logic ── - if (result.passed) { - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - return "continue"; - } else if (postExecBlockingFailure) { - // Post-execution failures are cross-task consistency issues — retrying the same task won't fix them. - // Skip retry and pause immediately for human review. - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - ctx.ui.notify( - `Post-execution checks failed — cross-task consistency issue detected, pausing for human review`, - "error", - ); - await pauseAuto(ctx, pi); - return "pause"; - } else if (autoFixEnabled && attempt + 1 <= maxRetries) { - const nextAttempt = attempt + 1; - s.verificationRetryCount.set(s.currentUnit.id, nextAttempt); - s.pendingVerificationRetry = { - unitId: s.currentUnit.id, - failureContext: formatFailureContext(result), - attempt: nextAttempt, - }; - const failedCmds = result.checks - .filter((c) => c.exitCode !== 0) - .map((c) => c.command); - const cmdSummary = failedCmds.length <= 3 - ? failedCmds.join(", ") - : `${failedCmds.slice(0, 3).join(", ")}... and ${failedCmds.length - 3} more`; - ctx.ui.notify( - `Verification failed (${cmdSummary}) — auto-fix attempt ${nextAttempt}/${maxRetries}`, - "warning", - ); - // Return "retry" — the autoLoop while loop will re-iterate with the retry context - return "retry"; - } else { - // Gate failed, retries exhausted - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - const exhaustedFails = result.checks - .filter((c) => c.exitCode !== 0) - .map((c) => c.command); - const exhaustedSummary = exhaustedFails.length <= 3 - ? exhaustedFails.join(", ") - : `${exhaustedFails.slice(0, 3).join(", ")}... and ${exhaustedFails.length - 3} more`; - ctx.ui.notify( - `Verification gate FAILED after ${attempt} ${attempt === 1 ? "retry" : "retries"} (${exhaustedSummary}) — pausing for human review`, - "error", - ); - await pauseAuto(ctx, pi); - return "pause"; - } - } catch (err) { - // Gate errors are non-fatal - logWarning("engine", `verification-gate error: ${(err as Error).message}`); - return "continue"; - } -} - -/** - * Write verification evidence JSON with post-execution checks included. - * This is a variant of writeVerificationJSON that adds the postExecutionChecks field. - */ -function writeVerificationJSONWithPostExec( - result: VerificationGateResult, - tasksDir: string, - taskId: string, - unitId: string, - postExecutionChecks: PostExecutionCheckJSON[], - retryAttempt?: number, - maxRetries?: number, -): void { - mkdirSync(tasksDir, { recursive: true }); - - const evidence: EvidenceJSON = { - schemaVersion: 1, - taskId, - unitId: unitId ?? taskId, - timestamp: result.timestamp, - passed: result.passed, - discoverySource: result.discoverySource, - checks: result.checks.map((check) => ({ - command: check.command, - exitCode: check.exitCode, - durationMs: check.durationMs, - verdict: check.exitCode === 0 ? "pass" : "fail", - })), - ...(retryAttempt !== undefined ? { retryAttempt } : {}), - ...(maxRetries !== undefined ? { maxRetries } : {}), - postExecutionChecks, - }; - - if (result.runtimeErrors && result.runtimeErrors.length > 0) { - evidence.runtimeErrors = result.runtimeErrors.map(e => ({ - source: e.source, - severity: e.severity, - message: e.message, - blocking: e.blocking, - })); - } - - if (result.auditWarnings && result.auditWarnings.length > 0) { - evidence.auditWarnings = result.auditWarnings.map(w => ({ - name: w.name, - severity: w.severity, - title: w.title, - url: w.url, - fixAvailable: w.fixAvailable, - })); - } - - const filePath = join(tasksDir, `${taskId}-VERIFY.json`); - writeFileSync(filePath, JSON.stringify(evidence, null, 2) + "\n", "utf-8"); -} diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts deleted file mode 100644 index 4b80bc88b..000000000 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ /dev/null @@ -1,2067 +0,0 @@ -/** - * SF Auto-Worktree -- lifecycle management for auto-mode worktrees. - * - * Auto-mode creates worktrees with `milestone/` branches (distinct from - * manual `/worktree` which uses `worktree/` branches). This module - * manages create, enter, detect, and teardown for auto-mode worktrees. - */ - -import { - existsSync, - cpSync, - readFileSync, - readdirSync, - mkdirSync, - realpathSync, - rmSync, - unlinkSync, - statSync, - lstatSync as lstatSyncFn, -} from "node:fs"; -import { isAbsolute, join, sep as pathSep } from "node:path"; -import { homedir } from "node:os"; -import { GSDError, SF_IO_ERROR, SF_GIT_ERROR } from "./errors.js"; -import { - reconcileWorktreeDb, - isDbAvailable, - getMilestone, - getMilestoneSlices, -} from "./gsd-db.js"; -import { atomicWriteSync } from "./atomic-write.js"; -import { execFileSync } from "node:child_process"; -import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; -import { gsdRoot } from "./paths.js"; -import { - createWorktree, - removeWorktree, - resolveGitDir, - worktreePath, - isInsideWorktreesDir, -} from "./worktree-manager.js"; -import { - detectWorktreeName, - resolveGitHeadPath, - nudgeGitBranchCache, -} from "./worktree.js"; -import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js"; -import { debugLog } from "./debug-logger.js"; -import { logWarning, logError } from "./workflow-logger.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { - nativeGetCurrentBranch, - nativeDetectMainBranch, - nativeWorkingTreeStatus, - nativeAddAllWithExclusions, - nativeCommit, - nativeCheckoutBranch, - nativeMergeSquash, - nativeConflictFiles, - nativeCheckoutTheirs, - nativeAddPaths, - nativeRmForce, - nativeBranchDelete, - nativeBranchExists, - nativeDiffNumstat, - nativeUpdateRef, - nativeIsAncestor, - nativeMergeAbort, -} from "./native-git-bridge.js"; - -const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); -const PROJECT_PREFERENCES_FILE = "PREFERENCES.md"; -const LEGACY_PROJECT_PREFERENCES_FILE = "preferences.md"; - -// ─── Shared Constants & Helpers ───────────────────────────────────────────── - -/** - * Root-level .gsd/ state files synced between worktree and project root. - * Single source of truth — used by syncGsdStateToWorktree, syncWorktreeStateBack, - * and the dispatch-level sync functions. - */ -const ROOT_STATE_FILES = [ - "DECISIONS.md", - "REQUIREMENTS.md", - "PROJECT.md", - "KNOWLEDGE.md", - "OVERRIDES.md", - "QUEUE.md", - "completed-units.json", - "metrics.json", - "mcp.json", - // NOTE: project preferences are intentionally NOT in ROOT_STATE_FILES. - // Forward-sync (main → worktree) is handled explicitly in syncGsdStateToWorktree(). - // Back-sync (worktree → main) must NEVER overwrite the project root's copy - // because the project root is authoritative for preferences (#2684). -] as const; - -/** - * Check if two filesystem paths resolve to the same real location. - * Returns false if either path cannot be resolved (e.g. doesn't exist). - */ -function isSamePath(a: string, b: string): boolean { - try { - return realpathSync(a) === realpathSync(b); - } catch (e) { - logWarning("worktree", `isSamePath failed: ${(e as Error).message}`); - return false; - } -} - -// ─── ASSESSMENT Force-Sync Helper (#2821) ───────────────────────────────── - -/** Regex matching YAML frontmatter `verdict:` field. */ -const VERDICT_RE = /verdict:\s*[\w-]+/i; - -/** - * Walk a milestone directory and force-overwrite ASSESSMENT files in the - * destination when the source copy contains a `verdict:` field. - * - * This is the targeted fix for the UAT stuck-loop (#2821): the main - * safeCopyRecursive uses force:false to protect worktree-authoritative - * files (#1886), but ASSESSMENT files written by run-uat must be - * forward-synced when the project root has a verdict. Without this, - * the worktree retains a stale FAIL or missing ASSESSMENT and - * checkNeedsRunUat re-dispatches run-uat indefinitely. - * - * Only overwrites when the source has a verdict — never clobbers a - * worktree ASSESSMENT with a verdictless project-root copy. - */ -function forceOverwriteAssessmentsWithVerdict( - srcMilestoneDir: string, - dstMilestoneDir: string, -): void { - if (!existsSync(srcMilestoneDir)) return; - - // Walk slices// looking for *-ASSESSMENT.md files - const slicesDir = join(srcMilestoneDir, "slices"); - if (!existsSync(slicesDir)) return; - - try { - for (const sliceEntry of readdirSync(slicesDir, { withFileTypes: true })) { - if (!sliceEntry.isDirectory()) continue; - const srcSliceDir = join(slicesDir, sliceEntry.name); - const dstSliceDir = join(dstMilestoneDir, "slices", sliceEntry.name); - - try { - for (const fileEntry of readdirSync(srcSliceDir, { withFileTypes: true })) { - if (!fileEntry.isFile()) continue; - if (!fileEntry.name.endsWith("-ASSESSMENT.md")) continue; - - const srcFile = join(srcSliceDir, fileEntry.name); - try { - const srcContent = readFileSync(srcFile, "utf-8"); - if (!VERDICT_RE.test(srcContent)) continue; // no verdict in source — skip - - // Source has a verdict — force-copy into worktree - mkdirSync(dstSliceDir, { recursive: true }); - safeCopy(srcFile, join(dstSliceDir, fileEntry.name), { force: true }); - } catch (err) { - /* non-fatal per file */ - logWarning("worktree", `assessment force-copy failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } catch (err) { - /* non-fatal per slice */ - logWarning("worktree", `assessment slice scan failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `assessment sync failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -// ─── Module State ────────────────────────────────────────────────────────── - -/** Original project root before chdir into auto-worktree. */ -let originalBase: string | null = null; - -function clearProjectRootStateFiles(basePath: string, milestoneId: string): void { - const gsdDir = gsdRoot(basePath); - const transientFiles = [ - join(gsdDir, "STATE.md"), - join(gsdDir, "auto.lock"), - join(gsdDir, "milestones", milestoneId, `${milestoneId}-META.json`), - ]; - - for (const file of transientFiles) { - try { - unlinkSync(file); - } catch (err) { - // ENOENT is expected — file may not exist (#3597) - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - logWarning("worktree", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // Clean up entire synced milestone directory and runtime/units. - // syncStateToProjectRoot() copies these into the project root during - // execution. If they remain as untracked files when we attempt - // `git merge --squash`, git rejects the merge with "local changes would - // be overwritten", causing silent data loss (#1738). - const syncedDirs = [ - join(gsdDir, "milestones", milestoneId), - join(gsdDir, "runtime", "units"), - ]; - - for (const dir of syncedDirs) { - try { - if (existsSync(dir)) { - // Only remove files that are untracked by git — tracked files are - // managed by the branch checkout and should not be deleted. - const untrackedOutput = execFileSync( - "git", - ["ls-files", "--others", "--exclude-standard", dir], - { cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, - ).trim(); - if (untrackedOutput) { - for (const f of untrackedOutput.split("\n").filter(Boolean)) { - try { - unlinkSync(join(basePath, f)); - } catch (err) { - // ENOENT/EISDIR are expected for already-removed or directory entries (#3597) - const code = (err as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "EISDIR") { - logWarning("worktree", `untracked file unlink failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - } - } - } catch (err) { - /* non-fatal — git command may fail if not in repo */ - logWarning("worktree", `untracked file cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - } -} - -// ─── Build Artifact Auto-Resolve ───────────────────────────────────────────── - -/** Patterns for machine-generated build artifacts that can be safely - * auto-resolved by accepting --theirs during merge. These files are - * regenerable and never contain meaningful manual edits. */ -export const SAFE_AUTO_RESOLVE_PATTERNS: RegExp[] = [ - /\.tsbuildinfo$/, - /\.pyc$/, - /\/__pycache__\//, - /\.DS_Store$/, - /\.map$/, -]; - -/** Returns true if the file path is safe to auto-resolve during merge. - * Covers `.gsd/` state files and common build artifacts. */ -export const isSafeToAutoResolve = (filePath: string): boolean => - filePath.startsWith(".gsd/") || - SAFE_AUTO_RESOLVE_PATTERNS.some((re) => re.test(filePath)); - -// ─── Dispatch-Level Sync (project root ↔ worktree) ────────────────────────── - -/** - * Sync milestone artifacts from project root INTO worktree before deriveState. - * Covers the case where the LLM wrote artifacts to the main repo filesystem - * (e.g. via absolute paths) but the worktree has stale data. Also deletes - * gsd.db in the worktree so it rebuilds from fresh disk state (#853). - * Non-fatal — sync failure should never block dispatch. - */ -export function syncProjectRootToWorktree( - projectRoot: string, - worktreePath_: string, - milestoneId: string | null, -): void { - if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return; - if (!milestoneId) return; - - const prGsd = join(projectRoot, ".gsd"); - const wtGsd = join(worktreePath_, ".gsd"); - - // When .gsd 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; - - // Copy milestone directory from project root to worktree — additive only. - // force:false prevents cpSync from overwriting existing worktree files. - // Without this, worktree-authoritative files (e.g. VALIDATION.md written - // 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), - { force: false }, - ); - - // Force-sync ASSESSMENT files that have a verdict from project root (#2821). - // The additive-only copy above preserves worktree-authoritative files, but - // ASSESSMENT files are special: after run-uat writes a verdict and post-unit - // syncs it to the project root, the worktree may retain a stale copy (e.g. - // verdict:fail while the project root has verdict:pass from a retry). On - // session resume the DB is rebuilt from disk, and if the stale ASSESSMENT - // persists, checkNeedsRunUat finds no passing verdict → re-dispatches - // run-uat indefinitely (stuck-loop ×9). - forceOverwriteAssessmentsWithVerdict( - join(prGsd, "milestones", milestoneId), - join(wtGsd, "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"), - { force: true }, - ); - - // Delete worktree gsd.db ONLY if it is empty (0 bytes). - // An empty DB is stale/corrupt and should be rebuilt (#853). - // A non-empty DB was populated by gsd-migrate on respawn and must be - // 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, "gsd.db"); - let deleteSidecars = false; - if (existsSync(wtDb)) { - const size = statSync(wtDb).size; - if (size === 0) { - unlinkSync(wtDb); - deleteSidecars = true; - } - } else { - // Main DB already missing — sidecars are orphaned from a previous - // partial cleanup and must still be removed. - deleteSidecars = true; - } - // Always clean up WAL/SHM sidecar files when the main DB was deleted - // or is already missing. Orphaned WAL/SHM files cause SQLite WAL - // recovery on next open, which triggers a CPU spin on Node 24's - // node:sqlite DatabaseSync implementation (#2478). - if (deleteSidecars) { - for (const suffix of ["-wal", "-shm"]) { - const f = wtDb + suffix; - if (existsSync(f)) { - unlinkSync(f); - } - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `worktree DB cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Sync dispatch-critical .gsd/ state files from worktree to project root. - * Only runs when inside an auto-worktree (worktreePath differs from projectRoot). - * Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries). - * Non-fatal — sync failure should never block dispatch. - */ -export function syncStateToProjectRoot( - worktreePath_: string, - projectRoot: string, - milestoneId: string | null, -): void { - if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return; - if (!milestoneId) return; - - const wtGsd = join(worktreePath_, ".gsd"); - const prGsd = join(projectRoot, ".gsd"); - - // When .gsd 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; - - // 1. STATE.md — the quick-glance status used by initial deriveState() - safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true }); - - // 2. Milestone directory — ROADMAP, slice PLANs, task summaries - // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes - safeCopyRecursive( - join(wtGsd, "milestones", milestoneId), - join(prGsd, "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"), { force: true }); - - // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords(). - // Without this, a crash during a unit leaves the runtime record only in the - // 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"), - { force: true }, - ); -} - -// ─── Resource Staleness ─────────────────────────────────────────────────── - -/** - * Read the resource version (semver) from the managed-resources manifest. - * Uses gsdVersion instead of syncedAt so that launching a second session - * doesn't falsely trigger staleness (#804). - */ -export function readResourceVersion(): string | null { - const agentDir = - process.env.SF_CODING_AGENT_DIR || join(gsdHome, "agent"); - const manifestPath = join(agentDir, "managed-resources.json"); - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - return typeof manifest?.gsdVersion === "string" - ? manifest.gsdVersion - : null; - } catch (e) { - logWarning("worktree", `readResourceVersion failed: ${(e as Error).message}`); - return null; - } -} - -/** - * Check if managed resources have been updated since session start. - * Returns a warning message if stale, null otherwise. - */ -export function checkResourcesStale( - versionOnStart: string | null, -): string | null { - if (versionOnStart === null) return null; - const current = readResourceVersion(); - if (current === null) return null; - if (current !== versionOnStart) { - return "SF resources were updated since this session started. Restart gsd to load the new code."; - } - return null; -} - -// ─── Stale Worktree Escape ──────────────────────────────────────────────── - -/** - * Detect and escape a stale worktree cwd (#608). - * - * After milestone completion + merge, the worktree directory is removed but - * the process cwd may still point inside `.gsd/worktrees//`. - * When a new session starts, `process.cwd()` is passed as `base` to startAuto - * and all subsequent writes land in the wrong directory. This function detects - * that scenario and chdir back to the project root. - * - * Returns the corrected base path. - */ -export function escapeStaleWorktree(base: string): string { - // Direct layout: /.gsd/worktrees/ - const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; - let idx = base.indexOf(directMarker); - if (idx === -1) { - // Symlink-resolved layout: /.gsd/projects//worktrees/ - const symlinkRe = new RegExp( - `\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`, - ); - const match = base.match(symlinkRe); - if (!match || match.index === undefined) return base; - idx = match.index; - } - - // base is inside .gsd/worktrees/ — extract the project root - const projectRoot = base.slice(0, idx); - - // Guard: If the candidate project root's .gsd IS the user-level ~/.gsd, - // the string-slice heuristic matched the wrong /.gsd/ boundary. This happens - // when .gsd is a symlink into ~/.gsd/projects/ and process.cwd() - // resolved through the symlink. Returning ~ would be catastrophic (#1676). - const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/"); - const gsdHomePath = gsdHome.replaceAll("\\", "/"); - if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) { - // Don't chdir to home — return base unchanged. - // resolveProjectRoot() in worktree.ts has the full git-file-based recovery - // and will be called by the caller (startAuto → projectRoot()). - return base; - } - - try { - process.chdir(projectRoot); - } catch (e) { - // If chdir fails, return the original — caller will handle errors downstream - logWarning("worktree", `escapeStaleWorktree chdir failed: ${(e as Error).message}`); - return base; - } - return projectRoot; -} - -/** - * Clean stale runtime unit files for completed milestones. - * - * After restart, stale runtime/units/*.json from prior milestones can - * cause deriveState to resume the wrong milestone (#887). Removes files - * for milestones that have a SUMMARY (fully complete). - */ -export function cleanStaleRuntimeUnits( - gsdRootPath: string, - hasMilestoneSummary: (mid: string) => boolean, -): number { - const runtimeUnitsDir = join(gsdRootPath, "runtime", "units"); - if (!existsSync(runtimeUnitsDir)) return 0; - - let cleaned = 0; - try { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) continue; - const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); - if (!midMatch) continue; - if (hasMilestoneSummary(midMatch[1])) { - try { - unlinkSync(join(runtimeUnitsDir, file)); - cleaned++; - } catch (err) { - /* non-fatal */ - logWarning("worktree", `stale runtime unit unlink failed (${file}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `stale runtime unit cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - return cleaned; -} - -// ─── Worktree ↔ Main Repo Sync (#1311) ────────────────────────────────────── - -/** - * Sync .gsd/ state from the main repo into the worktree. - * - * When .gsd/ is a symlink to the external state directory, both the main - * repo and worktree share the same directory — no sync needed. - * - * When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false), - * the worktree has its own copy that may be stale. This function copies - * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and - * PROJECT files from the main repo's .gsd/ into the worktree's .gsd/. - * - * Only adds missing content — never overwrites existing files in the worktree - * (the worktree's execution state is authoritative for in-progress work). - */ -export function syncGsdStateToWorktree( - mainBasePath: string, - worktreePath_: string, -): { synced: string[] } { - const mainGsd = gsdRoot(mainBasePath); - const wtGsd = gsdRoot(worktreePath_); - const synced: string[] = []; - - // If both resolve to the same directory (symlink), no sync needed - if (isSamePath(mainGsd, wtGsd)) return { synced }; - - if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced }; - - // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.) - for (const f of ROOT_STATE_FILES) { - const src = join(mainGsd, f); - const dst = join(wtGsd, f); - if (existsSync(src) && !existsSync(dst)) { - try { - cpSync(src, dst); - synced.push(f); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `file copy failed (${f}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // Forward-sync project preferences from project root to worktree (additive only). - // Prefer the canonical uppercase file name, but keep the legacy lowercase - // 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)); - 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); - if (existsSync(src)) { - try { - cpSync(src, dst); - synced.push(file); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `preferences copy failed (${file}): ${err instanceof Error ? err.message : String(err)}`); - } - break; - } - } - } - } - - // Sync milestones: copy entire milestone directories that are missing - const mainMilestonesDir = join(mainGsd, "milestones"); - const wtMilestonesDir = join(wtGsd, "milestones"); - if (existsSync(mainMilestonesDir)) { - try { - mkdirSync(wtMilestonesDir, { recursive: true }); - const mainMilestones = readdirSync(mainMilestonesDir, { - withFileTypes: true, - }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const mid of mainMilestones) { - const srcDir = join(mainMilestonesDir, mid); - const dstDir = join(wtMilestonesDir, mid); - - if (!existsSync(dstDir)) { - // Entire milestone missing from worktree — copy it - try { - cpSync(srcDir, dstDir, { recursive: true }); - synced.push(`milestones/${mid}/`); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone copy failed (${mid}): ${err instanceof Error ? err.message : String(err)}`); - } - } else { - // Milestone directory exists but may be missing files (stale snapshot). - // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.) - try { - const srcFiles = readdirSync(srcDir).filter( - (f) => f.endsWith(".md") || f.endsWith(".json"), - ); - for (const f of srcFiles) { - const srcFile = join(srcDir, f); - const dstFile = join(dstDir, f); - if (!existsSync(dstFile)) { - try { - const srcStat = lstatSyncFn(srcFile); - if (srcStat.isFile()) { - cpSync(srcFile, dstFile); - synced.push(`milestones/${mid}/${f}`); - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone file copy failed (${mid}/${f}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // Sync slices directory if it exists in main but not in worktree - const srcSlicesDir = join(srcDir, "slices"); - const dstSlicesDir = join(dstDir, "slices"); - if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) { - try { - cpSync(srcSlicesDir, dstSlicesDir, { recursive: true }); - synced.push(`milestones/${mid}/slices/`); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `slices copy failed (${mid}): ${err instanceof Error ? err.message : String(err)}`); - } - } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) { - // Both exist — sync missing slice directories - const srcSlices = readdirSync(srcSlicesDir, { - withFileTypes: true, - }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - for (const sid of srcSlices) { - const srcSlice = join(srcSlicesDir, sid); - const dstSlice = join(dstSlicesDir, sid); - if (!existsSync(dstSlice)) { - try { - cpSync(srcSlice, dstSlice, { recursive: true }); - synced.push(`milestones/${mid}/slices/${sid}/`); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `slice copy failed (${mid}/${sid}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone file sync failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone directory sync failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - return { synced }; -} - -/** - * Sync milestone artifacts from worktree back to the main external state directory. - * Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION, - * updated ROADMAP) are visible from the project root (#1412). - * - * Syncs: - * 1. Root-level .gsd/ files (REQUIREMENTS, PROJECT, DECISIONS, KNOWLEDGE, - * OVERRIDES) — the worktree's versions overwrite main's because the - * worktree is the authoritative execution context. - * 2. ALL milestone directories found in the worktree — not just the - * current milestoneId. The complete-milestone unit may create artifacts - * for the *next* milestone (CONTEXT, ROADMAP, new requirements) which - * must survive worktree teardown. - * - * History: Originally only synced milestones// and assumed - * root-level files would be carried by the squash merge. In practice, - * .gsd/ files are often untracked (gitignored or never committed), so the - * squash merge carries nothing. This caused next-milestone artifacts and - * updated REQUIREMENTS/PROJECT to be silently lost on teardown. - */ -export function syncWorktreeStateBack( - mainBasePath: string, - worktreePath: string, - milestoneId: string, -): { synced: string[] } { - const mainGsd = gsdRoot(mainBasePath); - const wtGsd = gsdRoot(worktreePath); - const synced: string[] = []; - - // If both resolve to the same directory (symlink), no sync needed - if (isSamePath(mainGsd, wtGsd)) return { synced }; - - if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced }; - - // ── 0. Pre-upgrade worktree DB reconciliation ──────────────────────── - // If the worktree has its own gsd.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, "gsd.db"); - const mainDb = join(mainGsd, "gsd.db"); - if (existsSync(wtLocalDb) && existsSync(mainDb)) { - try { - reconcileWorktreeDb(mainDb, wtLocalDb); - synced.push("gsd.db (pre-upgrade reconcile)"); - } catch (err) { - // Non-fatal — file sync below is the fallback - logError("worktree", `DB reconciliation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // ── 1. Sync root-level .gsd/ files back ────────────────────────────── - // The worktree is authoritative — complete-milestone updates REQUIREMENTS, - // PROJECT, etc. These must overwrite main's copies so they survive teardown. - // Also includes QUEUE.md, completed-units.json, and metrics.json which are - // 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); - if (existsSync(src)) { - try { - cpSync(src, dst, { force: true }); - synced.push(f); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `state file copy-back failed (${f}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // ── 2. Sync ALL milestone directories ──────────────────────────────── - // The complete-milestone unit may create next-milestone artifacts (e.g. - // M007 setup while closing M006). We must sync every milestone directory - // in the worktree, not just the current one. - const wtMilestonesDir = join(wtGsd, "milestones"); - if (!existsSync(wtMilestonesDir)) return { synced }; - - try { - const wtMilestones = readdirSync(wtMilestonesDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const mid of wtMilestones) { - // 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); - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone sync-back failed: ${err instanceof Error ? err.message : String(err)}`); - } - - return { synced }; -} - -/** - * Sync a single milestone directory from worktree to main. - * Copies milestone-level .md files, slice-level files, and task summaries. - */ -/** Copy matching files from srcDir to dstDir (non-fatal per file). */ -function syncDirFiles( - srcDir: string, - dstDir: string, - filter: (name: string) => boolean, - synced: string[], - prefix: string, -): void { - try { - for (const entry of readdirSync(srcDir, { withFileTypes: true })) { - if (!entry.isFile() || !filter(entry.name)) continue; - try { - cpSync(join(srcDir, entry.name), join(dstDir, entry.name), { force: true }); - synced.push(`${prefix}${entry.name}`); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `file copy failed (${prefix}${entry.name}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } catch (err) { - /* non-fatal — srcDir may not be readable */ - logWarning("worktree", `directory read failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -function syncMilestoneDir( - wtGsd: string, - mainGsd: string, - mid: string, - synced: string[], -): void { - const wtMilestoneDir = join(wtGsd, "milestones", mid); - const mainMilestoneDir = join(mainGsd, "milestones", mid); - - if (!existsSync(wtMilestoneDir)) return; - mkdirSync(mainMilestoneDir, { recursive: true }); - - const isMd = (name: string): boolean => name.endsWith(".md"); - - // Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT) - syncDirFiles(wtMilestoneDir, mainMilestoneDir, isMd, synced, `milestones/${mid}/`); - - // Sync slice-level files (summaries, UATs) and task summaries (#1678) - const wtSlicesDir = join(wtMilestoneDir, "slices"); - const mainSlicesDir = join(mainMilestoneDir, "slices"); - if (!existsSync(wtSlicesDir)) return; - - try { - for (const sliceEntry of readdirSync(wtSlicesDir, { withFileTypes: true })) { - if (!sliceEntry.isDirectory()) continue; - const sid = sliceEntry.name; - const wtSliceDir = join(wtSlicesDir, sid); - const mainSliceDir = join(mainSlicesDir, sid); - mkdirSync(mainSliceDir, { recursive: true }); - - syncDirFiles(wtSliceDir, mainSliceDir, isMd, synced, `milestones/${mid}/slices/${sid}/`); - - const wtTasksDir = join(wtSliceDir, "tasks"); - const mainTasksDir = join(mainSliceDir, "tasks"); - if (existsSync(wtTasksDir)) { - mkdirSync(mainTasksDir, { recursive: true }); - syncDirFiles(wtTasksDir, mainTasksDir, isMd, synced, `milestones/${mid}/slices/${sid}/tasks/`); - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `milestone slice sync failed (${mid}): ${err instanceof Error ? err.message : String(err)}`); - } -} -// ─── Worktree Post-Create Hook (#597) ──────────────────────────────────────── - -/** - * Run the user-configured post-create hook script after worktree creation. - * The script receives SOURCE_DIR and WORKTREE_DIR as environment variables. - * Failure is non-fatal — returns the error message or null on success. - * - * Reads the hook path from git.worktree_post_create in preferences. - * Pass hookPath directly to bypass preference loading (useful for testing). - */ -export function runWorktreePostCreateHook( - sourceDir: string, - worktreeDir: string, - hookPath?: string, -): string | null { - if (hookPath === undefined) { - const prefs = loadEffectiveGSDPreferences()?.preferences?.git; - hookPath = prefs?.worktree_post_create; - } - if (!hookPath) return null; - - // Resolve relative paths against the source project root. - // On Windows, convert 8.3 short paths (e.g. RUNNER~1) to long paths - // so execFileSync can locate the file correctly. - let resolved = isAbsolute(hookPath) ? hookPath : join(sourceDir, hookPath); - if (!existsSync(resolved)) { - return `Worktree post-create hook not found: ${resolved}`; - } - if (process.platform === "win32") { - try { resolved = realpathSync.native(resolved); } catch (err) { /* keep original */ - logWarning("worktree", `realpath failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - try { - // .bat/.cmd files on Windows require shell mode — execFileSync cannot - // spawn them directly (EINVAL). - const needsShell = process.platform === "win32" && /\.(bat|cmd)$/i.test(resolved); - execFileSync(resolved, [], { - cwd: worktreeDir, - env: { - ...process.env, - SOURCE_DIR: sourceDir, - WORKTREE_DIR: worktreeDir, - }, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - timeout: 30_000, // 30 second timeout - shell: needsShell, - }); - return null; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return `Worktree post-create hook failed: ${msg}`; - } -} - -// ─── Auto-Worktree Branch Naming ─────────────────────────────────────────── - -export function autoWorktreeBranch(milestoneId: string): string { - return `milestone/${milestoneId}`; -} - -// ─── Public API ──────────────────────────────────────────────────────────── - -/** - * Create a new auto-worktree for a milestone, chdir into it, and store - * the original base path for later teardown. - * - * Atomic: chdir + originalBase update happen in the same try block - * to prevent split-brain. - */ - -/** - * Forward-merge plan checkbox state from the project root into a freshly - * re-attached worktree (#778). - * - * When auto-mode stops via crash (not graceful stop), the milestone branch - * HEAD may be behind the filesystem state at the project root because - * syncStateToProjectRoot() runs after every task completion but the final - * git commit may not have happened before the crash. On restart the worktree - * is re-attached to the branch HEAD, which has [ ] for the crashed task, - * causing verifyExpectedArtifact() to fail and triggering an infinite - * dispatch/skip loop. - * - * Fix: after re-attaching, read every *.md plan file in the milestone - * directory at the project root and apply any [x] checkbox states that are - * ahead of the worktree version (forward-only: never downgrade [x] → [ ]). - * - * This is safe because syncStateToProjectRoot() is the authoritative source - * of post-task state at the project root — it writes the same [x] the LLM - * produced, then the auto-commit follows. If the commit never happened, the - * filesystem copy is still valid and correct. - */ -function reconcilePlanCheckboxes( - projectRoot: string, - wtPath: string, - milestoneId: string, -): void { - const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId); - const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId); - if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return; - - // Walk all markdown files in the milestone directory (plans, summaries, etc.) - function walkMd(dir: string): string[] { - const results: string[] = []; - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkMd(full)); - } else if (entry.isFile() && entry.name.endsWith(".md")) { - results.push(full); - } - } - } catch (err) { - /* non-fatal */ - logWarning("worktree", `walkMd directory read failed: ${err instanceof Error ? err.message : String(err)}`); - } - return results; - } - - for (const srcFile of walkMd(srcMilestone)) { - const rel = srcFile.slice(srcMilestone.length); - const dstFile = dstMilestone + rel; - if (!existsSync(dstFile)) continue; // only reconcile existing files - - let srcContent: string; - let dstContent: string; - try { - srcContent = readFileSync(srcFile, "utf-8"); - dstContent = readFileSync(dstFile, "utf-8"); - } catch (e) { - logWarning("worktree", `reconcilePlanCheckboxes read failed: ${(e as Error).message}`); - continue; - } - - if (srcContent === dstContent) continue; - - // Extract all checked task IDs from the source (project root) - // Pattern: - [x] **T: or - [x] **S: (case-insensitive x) - const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm; - const srcChecked = new Set(); - for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]); - - if (srcChecked.size === 0) continue; - - // Forward-apply: replace [ ] → [x] for any IDs that are checked in src - let updated = dstContent; - let changed = false; - for (const id of srcChecked) { - const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const uncheckedRe = new RegExp( - `^(- )\\[ \\]( \\*\\*${escapedId}:)`, - "gm", - ); - if (uncheckedRe.test(updated)) { - updated = updated.replace( - new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"), - "$1[x]$2", - ); - changed = true; - } - } - - if (changed) { - try { - atomicWriteSync(dstFile, updated, "utf-8"); - } catch (err) { - /* non-fatal */ - logWarning("worktree", `plan checkbox reconcile write failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } -} - -export function createAutoWorktree( - basePath: string, - milestoneId: string, -): string { - const branch = autoWorktreeBranch(milestoneId); - - // Check if the milestone branch already exists — it survives auto-mode - // stop/pause and contains committed work from prior sessions. If it exists, - // re-attach the worktree to it WITHOUT resetting. Only create a fresh branch - // from the integration branch when no prior work exists. - const branchExists = nativeBranchExists(basePath, branch); - - let info: { name: string; path: string; branch: string; exists: boolean }; - if (branchExists) { - // Re-attach worktree to the existing milestone branch (preserving commits) - info = createWorktree(basePath, milestoneId, { - branch, - reuseExistingBranch: true, - }); - } else { - // Fresh start — create branch from integration branch. - // Use the same 3-tier fallback as mergeMilestoneToMain (#3461): - // 1. META.json integration branch (explicit per-milestone override) - // 2. git.main_branch preference (user's configured working branch) - // 3. nativeDetectMainBranch (origin/HEAD auto-detection) - // Without tier 2, projects with main_branch=dev but origin/HEAD→master - // would fork worktrees from the wrong (stale) branch. - const integrationBranch = - readIntegrationBranch(basePath, milestoneId) ?? undefined; - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; - const startPoint = integrationBranch ?? gitPrefs?.main_branch ?? undefined; - info = createWorktree(basePath, milestoneId, { - branch, - startPoint, - }); - } - - // Copy .gsd/ planning artifacts from the source repo into the new worktree. - // Worktrees are fresh git checkouts — untracked files don't carry over. - // Planning artifacts may be untracked if the project's .gitignore had a - // blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops - // on plan-slice because the plan file doesn't exist in the worktree. - // - // IMPORTANT: Skip when re-attaching to an existing branch (#759). - // The branch checkout already has committed artifacts with correct state - // (e.g. [x] for completed slices). Copying from the project root would - // overwrite them with stale data ([ ] checkboxes) because the root is - // not always fully synced. - if (!branchExists) { - copyPlanningArtifacts(basePath, info.path); - } else { - // Re-attaching to an existing branch: forward-merge any plan checkpoint - // state from the project root into the worktree (#778). - // - // If auto-mode stopped via crash, the milestone branch HEAD may lag behind - // the project root filesystem because syncStateToProjectRoot() ran after - // task completion but the auto-commit never fired. On restart the worktree - // is re-created from the branch HEAD (which has [ ] for the crashed task), - // causing verifyExpectedArtifact() to return false → stale-key eviction → - // infinite dispatch/skip loop. Reconciling here ensures the worktree sees - // the same [x] state that syncStateToProjectRoot() wrote to the root. - reconcilePlanCheckboxes(basePath, info.path, milestoneId); - } - - // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets - const hookError = runWorktreePostCreateHook(basePath, info.path); - if (hookError) { - // Non-fatal — log but don't prevent worktree usage - logWarning("reconcile", hookError, { worktree: info.name }); - } - - const previousCwd = process.cwd(); - - try { - process.chdir(info.path); - originalBase = basePath; - } catch (err) { - // If chdir fails, the worktree was created but we couldn't enter it. - // Don't store originalBase -- caller can retry or clean up. - throw new GSDError( - SF_IO_ERROR, - `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - nudgeGitBranchCache(previousCwd); - return info.path; -} - -/** - * Copy .gsd/ planning artifacts from source repo to a new worktree. - * Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md, - * STATE.md, KNOWLEDGE.md, and OVERRIDES.md. - * Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir. - * Best-effort — failures are non-fatal since auto-mode can recreate artifacts. - */ -function copyPlanningArtifacts(srcBase: string, wtPath: string): void { - const srcGsd = join(srcBase, ".gsd"); - const dstGsd = join(wtPath, ".gsd"); - if (!existsSync(srcGsd)) return; - if (isSamePath(srcGsd, dstGsd)) return; - - // Copy milestones/ directory (planning files, roadmaps, plans, research) - safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), { - force: true, - filter: (src) => !src.endsWith("-META.json"), - }); - - // Copy top-level planning files - for (const file of [ - "DECISIONS.md", - "REQUIREMENTS.md", - "PROJECT.md", - "QUEUE.md", - "STATE.md", - "KNOWLEDGE.md", - "OVERRIDES.md", - "mcp.json", - ]) { - safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true }); - } - - // Seed canonical PREFERENCES.md when available; fall back to legacy lowercase. - if (existsSync(join(srcGsd, PROJECT_PREFERENCES_FILE))) { - safeCopy( - join(srcGsd, PROJECT_PREFERENCES_FILE), - join(dstGsd, PROJECT_PREFERENCES_FILE), - { force: true }, - ); - } else if (existsSync(join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE))) { - safeCopy( - join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE), - join(dstGsd, LEGACY_PROJECT_PREFERENCES_FILE), - { force: true }, - ); - } - - // Shared WAL (R012): worktrees use the project root's DB directly. - // No longer copy gsd.db into the worktree — the DB path resolver in - // ensureDbOpen() detects the worktree location and opens the root DB. - // Compat note: reconcileWorktreeDb() in mergeMilestoneToMain handles - // worktrees that already have a local gsd.db from before this change. -} - -/** - * Teardown an auto-worktree: chdir back to original base, then remove - * the worktree and its branch. - */ -export function teardownAutoWorktree( - originalBasePath: string, - milestoneId: string, - opts: { preserveBranch?: boolean } = {}, -): void { - const branch = autoWorktreeBranch(milestoneId); - const { preserveBranch = false } = opts; - const previousCwd = process.cwd(); - - try { - process.chdir(originalBasePath); - originalBase = null; - } catch (err) { - throw new GSDError( - SF_IO_ERROR, - `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - nudgeGitBranchCache(previousCwd); - removeWorktree(originalBasePath, milestoneId, { - branch, - deleteBranch: !preserveBranch, - }); - - // Verify cleanup succeeded — warn if the worktree directory is still on disk. - // On Windows, bash-based cleanup can silently fail when paths contain - // backslashes (#1436), leaving ~1 GB+ orphaned directories. - const wtDir = worktreePath(originalBasePath, milestoneId); - if (existsSync(wtDir)) { - logWarning( - "reconcile", - `Worktree directory still exists after teardown: ${wtDir}. ` + - `This is likely an orphaned directory consuming disk space. ` + - `Remove it manually with: rm -rf "${wtDir.replaceAll("\\", "/")}"`, - { worktree: milestoneId }, - ); - // Attempt a direct filesystem removal as a fallback — but ONLY if the - // path is safely inside .gsd/worktrees/ to prevent #2365 data loss. - if (isInsideWorktreesDir(originalBasePath, wtDir)) { - try { - rmSync(wtDir, { recursive: true, force: true }); - } catch (err) { - // Non-fatal — the warning above tells the user how to clean up - logWarning("worktree", `worktree directory removal failed: ${err instanceof Error ? err.message : String(err)}`); - } - } else { - console.error( - `[SF] REFUSING fallback rmSync — path is outside .gsd/worktrees/: ${wtDir}`, - ); - } - } -} - -/** - * Detect if the process is currently inside an auto-worktree. - * Checks both module state and git branch prefix. - */ -export function isInAutoWorktree(basePath: string): boolean { - if (!originalBase) return false; - const cwd = process.cwd(); - const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath; - const wtDir = join(resolvedBase, ".gsd", "worktrees"); - if (!cwd.startsWith(wtDir)) return false; - const branch = nativeGetCurrentBranch(cwd); - return branch.startsWith("milestone/"); -} - -/** - * Get the filesystem path for an auto-worktree, or null if it doesn't exist - * or is not a valid git worktree. - * - * Validates that the path is a real git worktree (has a .git file with a - * gitdir: pointer) rather than just a stray directory. This prevents - * mis-detection of leftover directories as active worktrees (#695). - */ -export function getAutoWorktreePath( - basePath: string, - milestoneId: string, -): string | null { - const p = worktreePath(basePath, milestoneId); - if (!existsSync(p)) return null; - - // Validate this is a real git worktree, not a stray directory. - // A git worktree has a .git *file* (not directory) containing "gitdir: ". - const gitPath = join(p, ".git"); - if (!existsSync(gitPath)) return null; - try { - const content = readFileSync(gitPath, "utf8").trim(); - if (!content.startsWith("gitdir: ")) return null; - } catch (e) { - logWarning("worktree", `getAutoWorktreePath .git read failed: ${(e as Error).message}`); - return null; - } - - return p; -} - -/** - * Enter an existing auto-worktree (chdir into it, store originalBase). - * Use for resume -- the worktree already exists from a prior create. - * - * Atomic: chdir + originalBase update in same try block. - */ -export function enterAutoWorktree( - basePath: string, - milestoneId: string, -): string { - const p = worktreePath(basePath, milestoneId); - if (!existsSync(p)) { - throw new GSDError( - SF_IO_ERROR, - `Auto-worktree for ${milestoneId} does not exist at ${p}`, - ); - } - - // Validate this is a real git worktree, not a stray directory (#695) - const gitPath = join(p, ".git"); - if (!existsSync(gitPath)) { - throw new GSDError( - SF_GIT_ERROR, - `Auto-worktree path ${p} exists but is not a git worktree (no .git)`, - ); - } - try { - const content = readFileSync(gitPath, "utf8").trim(); - if (!content.startsWith("gitdir: ")) { - throw new GSDError( - SF_GIT_ERROR, - `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`, - ); - } - } catch (err) { - if (err instanceof Error && err.message.includes("worktree")) throw err; - throw new GSDError( - SF_IO_ERROR, - `Auto-worktree path ${p} exists but .git is unreadable`, - ); - } - - const previousCwd = process.cwd(); - - try { - process.chdir(p); - originalBase = basePath; - } catch (err) { - throw new GSDError( - SF_IO_ERROR, - `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - nudgeGitBranchCache(previousCwd); - return p; -} - -/** - * Get the original project root stored when entering an auto-worktree. - * Returns null if not currently in an auto-worktree. - */ -export function getAutoWorktreeOriginalBase(): string | null { - return originalBase; -} - -export function getActiveAutoWorktreeContext(): { - originalBase: string; - worktreeName: string; - branch: string; -} | null { - if (!originalBase) return null; - const cwd = process.cwd(); - const resolvedBase = existsSync(originalBase) - ? realpathSync(originalBase) - : originalBase; - const wtDir = join(resolvedBase, ".gsd", "worktrees"); - if (!cwd.startsWith(wtDir)) return null; - const worktreeName = detectWorktreeName(cwd); - if (!worktreeName) return null; - const branch = nativeGetCurrentBranch(cwd); - if (!branch.startsWith("milestone/")) return null; - return { - originalBase, - worktreeName, - branch, - }; -} - -// ─── Merge Milestone -> Main ─────────────────────────────────────────────── - -/** - * Auto-commit any dirty (uncommitted) state in the given directory. - * Returns true if a commit was made, false if working tree was clean. - */ -function autoCommitDirtyState(cwd: string): boolean { - try { - const status = nativeWorkingTreeStatus(cwd); - if (!status) return false; - nativeAddAllWithExclusions(cwd, RUNTIME_EXCLUSION_PATHS); - const result = nativeCommit( - cwd, - "chore: auto-commit before milestone merge", - ); - return result !== null; - } catch (e) { - debugLog("autoCommitDirtyState", { error: String(e) }); - return false; - } -} - -/** - * Squash-merge the milestone branch into main with a rich commit message - * listing all completed slices, then tear down the worktree. - * - * Sequence: - * 1. Auto-commit dirty worktree state - * 2. chdir to originalBasePath - * 3. git checkout main - * 4. git merge --squash milestone/ - * 5. git commit with rich message - * 6. Auto-push if enabled - * 7. Delete milestone branch - * 8. Remove worktree directory - * 9. Clear originalBase - * - * On merge conflict: throws MergeConflictError. - * On "nothing to commit" after squash: safe only if milestone work is already - * on the integration branch. Throws if unanchored code changes would be lost. - */ -export function mergeMilestoneToMain( - originalBasePath_: string, - milestoneId: string, - roadmapContent: string, -): { commitMessage: string; pushed: boolean; prCreated: boolean; codeFilesChanged: boolean } { - const worktreeCwd = process.cwd(); - const milestoneBranch = autoWorktreeBranch(milestoneId); - - // 1. Auto-commit dirty state before leaving. - // Guard: when we entered through an auto-worktree (originalBase is set), - // only auto-commit when cwd is on the milestone branch. In parallel mode, - // cwd may be on the integration branch after a prior merge's - // MergeConflictError left cwd unrestored. Auto-committing on the - // integration branch captures dirty files from OTHER milestones under a - // misleading commit message, contaminating the main branch (#2929). - // - // When originalBase is null (branch mode, no worktree), autoCommitDirtyState - // runs unconditionally — the caller is responsible for cwd placement. - { - let shouldAutoCommit = true; - if (originalBase !== null) { - try { - const currentBranch = nativeGetCurrentBranch(worktreeCwd); - shouldAutoCommit = currentBranch === milestoneBranch; - } catch { - // If we can't determine the branch, skip the auto-commit to be safe - shouldAutoCommit = false; - } - } - if (shouldAutoCommit) { - autoCommitDirtyState(worktreeCwd); - } - } - - // Reconcile worktree DB into main DB before leaving worktree context. - // Skip when both paths resolve to the same physical file (shared WAL / - // symlink layout) — ATTACHing a WAL-mode file to itself corrupts the - // database (#2823). - if (isDbAvailable()) { - try { - const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db"); - const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db"); - if (!isSamePath(worktreeDbPath, mainDbPath)) { - reconcileWorktreeDb(mainDbPath, worktreeDbPath); - } - } catch (err) { - /* non-fatal */ - logError("worktree", `DB reconciliation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 2. Get completed slices for commit message - let completedSlices: { id: string; title: string }[] = []; - if (isDbAvailable()) { - completedSlices = getMilestoneSlices(milestoneId) - .filter(s => s.status === "complete") - .map(s => ({ id: s.id, title: s.title })); - } - // Fallback: parse roadmap content when DB is unavailable - if (completedSlices.length === 0 && roadmapContent) { - const sliceRe = /- \[x\] \*\*(\w+):\s*(.+?)\*\*/gi; - let m: RegExpExecArray | null; - while ((m = sliceRe.exec(roadmapContent)) !== null) { - completedSlices.push({ id: m[1], title: m[2] }); - } - } - - // 3. chdir to original base - const previousCwd = process.cwd(); - process.chdir(originalBasePath_); - - // 4. Resolve integration branch — prefer milestone metadata, then preferences, - // then auto-detect (origin/HEAD → main → master → current). Never hardcode - // "main": repos using "master" or a custom default branch would fail at - // checkout and leave the user with a broken merge state (#1668). - const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; - const integrationBranch = readIntegrationBranch( - originalBasePath_, - milestoneId, - ); - // Validate prefs.main_branch exists before using it — a stale preference - // (e.g. "master" when repo uses "main") causes merge failure (#3589). - const validatedPrefBranch = prefs.main_branch && nativeBranchExists(originalBasePath_, prefs.main_branch) - ? prefs.main_branch - : undefined; - const mainBranch = - integrationBranch ?? validatedPrefBranch ?? nativeDetectMainBranch(originalBasePath_); - - // Remove transient project-root state files before any branch or merge - // operation. Untracked milestone metadata can otherwise block squash merges. - clearProjectRootStateFiles(originalBasePath_, milestoneId); - - // 5. Checkout integration branch (skip if already current — avoids git error - // when main is already checked out in the project-root worktree, #757) - const currentBranchAtBase = nativeGetCurrentBranch(originalBasePath_); - if (currentBranchAtBase !== mainBranch) { - nativeCheckoutBranch(originalBasePath_, mainBranch); - } - - // 6. Build rich commit message - const dbMilestone = getMilestone(milestoneId); - let milestoneTitle = - (dbMilestone?.title ?? "").replace(/^M\d+:\s*/, "").trim(); - // Fallback: parse title from roadmap content header (e.g. "# M020: Backend foundation") - if (!milestoneTitle && roadmapContent) { - const titleMatch = roadmapContent.match(new RegExp(`^#\\s+${milestoneId}:\\s*(.+)`, "m")); - if (titleMatch) milestoneTitle = titleMatch[1].trim(); - } - milestoneTitle = milestoneTitle || milestoneId; - const subject = `feat: ${milestoneTitle}`; - let body = ""; - if (completedSlices.length > 0) { - const sliceLines = completedSlices - .map((s) => `- ${s.id}: ${s.title}`) - .join("\n"); - body = `\n\nCompleted slices:\n${sliceLines}\n\nGSD-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`; - } else { - body = `\n\nGSD-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`; - } - const commitMessage = subject + body; - - // 6b. Reconcile worktree HEAD with milestone branch ref (#1846). - // When the worktree HEAD detaches and advances past the named branch, - // the branch ref becomes stale. Squash-merging the stale ref silently - // orphans all commits between the branch ref and the actual worktree HEAD. - // Fix: fast-forward the branch ref to the worktree HEAD before merging. - // Only applies when merging from an actual worktree (worktreeCwd differs - // from originalBasePath_). - if (worktreeCwd !== originalBasePath_) { - try { - const worktreeHead = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: worktreeCwd, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - const branchHead = execFileSync("git", ["rev-parse", milestoneBranch], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - - if (worktreeHead && branchHead && worktreeHead !== branchHead) { - if (nativeIsAncestor(originalBasePath_, branchHead, worktreeHead)) { - // Worktree HEAD is strictly ahead — fast-forward the branch ref - nativeUpdateRef( - originalBasePath_, - `refs/heads/${milestoneBranch}`, - worktreeHead, - ); - debugLog("mergeMilestoneToMain", { - action: "fast-forward-branch-ref", - milestoneBranch, - oldRef: branchHead.slice(0, 8), - newRef: worktreeHead.slice(0, 8), - }); - } else { - // Diverged — fail loudly rather than silently losing commits - process.chdir(previousCwd); - throw new GSDError( - SF_GIT_ERROR, - `Worktree HEAD (${worktreeHead.slice(0, 8)}) diverged from ` + - `${milestoneBranch} (${branchHead.slice(0, 8)}). ` + - `Manual reconciliation required before merge.`, - ); - } - } - } catch (err) { - // Re-throw GSDError (divergence); swallow rev-parse failures - // (e.g. worktree dir already removed by external cleanup) - if (err instanceof GSDError) throw err; - debugLog("mergeMilestoneToMain", { - action: "reconcile-skipped", - reason: String(err), - }); - } - } - - // 7. Stash any pre-existing dirty files so the squash merge is not - // blocked by unrelated local changes (#2151). clearProjectRootStateFiles - // only removes untracked .gsd/ files; tracked dirty files elsewhere (e.g. - // .planning/work-state.json with stash conflict markers) are invisible to - // that cleanup but will cause `git merge --squash` to reject. - let stashed = false; - try { - const status = execFileSync("git", ["status", "--porcelain"], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - if (status) { - // Use --include-untracked to stash untracked files that would block - // the squash merge, but EXCLUDE .gsd/milestones/ (#2505). - // --include-untracked without exclusion sweeps queued milestone - // CONTEXT files into the stash. If stash pop later fails, those files - // are permanently trapped in the stash entry and lost on the next - // stash push or drop. - execFileSync( - "git", - [ - "stash", "push", "--include-untracked", - "-m", `gsd: pre-merge stash for ${milestoneId}`, - "--", ":(exclude).gsd/milestones", - ], - { cwd: originalBasePath_, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, - ); - stashed = true; - } - } catch (err) { - // Stash failure is non-fatal — proceed without stash and let the merge - // report the dirty tree if it fails. - logWarning("worktree", `git stash failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 7a. Shelter queued milestone directories before the squash merge (#2505). - // The milestone branch may contain copies of queued milestone dirs (via - // copyPlanningArtifacts), so `git merge --squash` rejects when those same - // files exist as untracked in the working tree. Temporarily move them to - // a backup location, then restore after the merge+commit. - const milestonesDir = join(gsdRoot(originalBasePath_), "milestones"); - const shelterDir = join(gsdRoot(originalBasePath_), ".milestone-shelter"); - const shelteredDirs: string[] = []; - - // Helper: restore sheltered milestone directories (#2505). - // Called on both success and error paths to ensure queued CONTEXT files - // are never permanently lost. - const restoreShelter = (): void => { - if (shelteredDirs.length === 0) return; - for (const dirName of shelteredDirs) { - try { - mkdirSync(milestonesDir, { recursive: true }); - cpSync(join(shelterDir, dirName), join(milestonesDir, dirName), { recursive: true, force: true }); - } catch (err) { /* best-effort */ - logError("worktree", `shelter restore failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - try { rmSync(shelterDir, { recursive: true, force: true }); } catch (err) { /* best-effort */ - logWarning("worktree", `shelter cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - }; - - try { - if (existsSync(milestonesDir)) { - const entries = readdirSync(milestonesDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - // Only shelter directories that do NOT belong to the milestone being merged - if (entry.name === milestoneId) continue; - const srcDir = join(milestonesDir, entry.name); - const dstDir = join(shelterDir, entry.name); - try { - mkdirSync(shelterDir, { recursive: true }); - cpSync(srcDir, dstDir, { recursive: true, force: true }); - rmSync(srcDir, { recursive: true, force: true }); - shelteredDirs.push(entry.name); - } catch (err) { - // Non-fatal — if shelter fails, the merge may still succeed - logWarning("worktree", `milestone shelter failed (${entry.name}): ${err instanceof Error ? err.message : String(err)}`); - } - } - } - } catch (err) { - // Non-fatal — proceed with merge; untracked files may block it - logWarning("worktree", `milestone shelter operation failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 7b. Clean up stale merge state before attempting squash merge (#2912). - // A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path, - // or interrupted operation) causes `git merge --squash` to refuse with - // "fatal: You have not concluded your merge (MERGE_HEAD exists)". - // Defensively remove merge artifacts before starting. - try { - const gitDir_ = resolveGitDir(originalBasePath_); - for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { - const p = join(gitDir_, f); - if (existsSync(p)) unlinkSync(p); - } - } catch (err) { /* best-effort */ - logError("worktree", `merge state cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 8. Squash merge — auto-resolve .gsd/ state file conflicts (#530) - const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch); - - if (!mergeResult.success) { - // Dirty working tree — the merge was rejected before it started (e.g. - // untracked .gsd/ files left by syncStateToProjectRoot). Preserve the - // milestone branch so commits are not lost. - if (mergeResult.conflicts.includes("__dirty_working_tree__")) { - // Defensively clean merge state — the native path may leave MERGE_HEAD - // even when the merge is rejected (#2912). - try { - const gitDir_ = resolveGitDir(originalBasePath_); - for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { - const p = join(gitDir_, f); - if (existsSync(p)) unlinkSync(p); - } - } catch (err) { /* best-effort */ - logError("worktree", `merge state cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // Pop stash before throwing so local work is not lost. - if (stashed) { - try { - execFileSync("git", ["stash", "pop"], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (err) { /* stash pop conflict is non-fatal */ - logWarning("worktree", `git stash pop failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - restoreShelter(); - // Restore cwd so the caller is not stranded on the integration branch - process.chdir(previousCwd); - // Surface the actual dirty filenames from git stderr instead of - // generically blaming .gsd/ (#2151). - const fileList = mergeResult.dirtyFiles?.length - ? `Dirty files:\n${mergeResult.dirtyFiles.map((f) => ` ${f}`).join("\n")}` - : `Check \`git status\` in the project root for details.`; - throw new GSDError( - SF_GIT_ERROR, - `Squash merge of ${milestoneBranch} rejected: working tree has dirty or untracked files ` + - `that conflict with the merge. ${fileList}`, - ); - } - - // Check for conflicts — use merge result first, fall back to nativeConflictFiles - const conflictedFiles = - mergeResult.conflicts.length > 0 - ? mergeResult.conflicts - : nativeConflictFiles(originalBasePath_); - - if (conflictedFiles.length > 0) { - // Separate auto-resolvable conflicts (SF state files + build artifacts) - // from real code conflicts. SF state files diverge between branches - // during normal operation. Build artifacts are machine-generated and - // regenerable. Both are safe to accept from the milestone branch. - const autoResolvable = conflictedFiles.filter(isSafeToAutoResolve); - const codeConflicts = conflictedFiles.filter( - (f) => !isSafeToAutoResolve(f), - ); - - // Auto-resolve safe conflicts by accepting the milestone branch version - if (autoResolvable.length > 0) { - for (const safeFile of autoResolvable) { - try { - nativeCheckoutTheirs(originalBasePath_, [safeFile]); - nativeAddPaths(originalBasePath_, [safeFile]); - } catch (e) { - // If checkout --theirs fails, try removing the file from the merge - // (it's a runtime file that shouldn't be committed anyway) - logWarning("worktree", `checkout --theirs failed for ${safeFile}, removing: ${(e as Error).message}`); - nativeRmForce(originalBasePath_, [safeFile]); - } - } - } - - // If there are still real code conflicts, escalate - if (codeConflicts.length > 0) { - // Abort merge state so MERGE_HEAD is not left on disk (#2912). - // libgit2's merge creates MERGE_HEAD even for squash merges; if left - // dangling, subsequent merges fail and doctor reports corrupt state. - try { nativeMergeAbort(originalBasePath_); } catch (err) { /* best-effort */ - logError("worktree", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`); - } - try { - const gitDir_ = resolveGitDir(originalBasePath_); - for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { - const p = join(gitDir_, f); - if (existsSync(p)) unlinkSync(p); - } - } catch (err) { /* best-effort */ - logError("worktree", `merge state file cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // Pop stash before throwing so local work is not lost (#2151). - if (stashed) { - try { - execFileSync("git", ["stash", "pop"], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (err) { /* stash pop conflict is non-fatal */ - logWarning("worktree", `git stash pop failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - restoreShelter(); - // Restore cwd so the caller is not stranded on the integration branch. - // Without this, the next mergeMilestoneToMain call in a parallel merge - // sequence uses process.cwd() (now the project root) as worktreeCwd, - // causing autoCommitDirtyState to commit unrelated milestone files to - // the integration branch (#2929). - process.chdir(previousCwd); - throw new MergeConflictError( - codeConflicts, - "squash", - milestoneBranch, - mainBranch, - ); - } - } - // No conflicts detected — possibly "already up to date", fall through to commit - } - - // 9. Commit (handle nothing-to-commit gracefully) - const commitResult = nativeCommit(originalBasePath_, commitMessage); - const nothingToCommit = commitResult === null; - - // 9a. Clean up merge state files left by git merge --squash (#1853, #2912). - // git only removes SQUASH_MSG when the commit reads it directly (plain - // `git commit`). nativeCommit uses `-F -` (stdin) or libgit2, neither - // of which trigger git's SQUASH_MSG cleanup. MERGE_HEAD is created by - // libgit2's merge even in squash mode and is not removed by nativeCommit. - // If left on disk, doctor reports `corrupt_merge_state` on every subsequent run. - try { - const gitDir_ = resolveGitDir(originalBasePath_); - for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { - const p = join(gitDir_, f); - if (existsSync(p)) unlinkSync(p); - } - } catch (err) { /* best-effort */ - logError("worktree", `post-commit merge state cleanup failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 9a-ii. Restore stashed files now that the merge+commit is complete (#2151). - // Pop after commit so stashed changes do not interfere with the squash merge - // or the commit content. Conflict on pop is non-fatal — the stash entry is - // preserved and the user can resolve manually with `git stash pop`. - if (stashed) { - try { - execFileSync("git", ["stash", "pop"], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (e) { - logWarning("worktree", `git stash pop failed, attempting conflict resolution: ${(e as Error).message}`); - // Stash pop after squash merge can conflict on .gsd/ state files that - // diverged between branches. Left unresolved, these UU entries block - // every subsequent merge. Auto-resolve them the same way we handle - // .gsd/ conflicts during the merge itself: accept HEAD (the just-committed - // version) and drop the now-applied stash. - const uu = nativeConflictFiles(originalBasePath_); - const gsdUU = uu.filter((f) => f.startsWith(".gsd/")); - const nonGsdUU = uu.filter((f) => !f.startsWith(".gsd/")); - - if (gsdUU.length > 0) { - for (const f of gsdUU) { - try { - // Accept the committed (HEAD) version of the state file - execFileSync("git", ["checkout", "HEAD", "--", f], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - nativeAddPaths(originalBasePath_, [f]); - } catch (e) { - // Last resort: remove the conflicted state file - logWarning("worktree", `checkout HEAD failed for ${f}, removing: ${(e as Error).message}`); - nativeRmForce(originalBasePath_, [f]); - } - } - } - - if (nonGsdUU.length === 0) { - // All conflicts were .gsd/ files — safe to drop the stash - try { - execFileSync("git", ["stash", "drop"], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (err) { /* stash may already be consumed */ - logWarning("worktree", `git stash drop failed: ${err instanceof Error ? err.message : String(err)}`); - } - } else { - // Non-.gsd conflicts remain — leave stash for manual resolution - logWarning("reconcile", "Stash pop conflict on non-.gsd files after merge", { - files: nonGsdUU.join(", "), - }); - } - } - } - - // 9a-iii. Restore sheltered queued milestone directories (#2505). - restoreShelter(); - - // 9b. Safety check (#1792): if nothing was committed, verify the milestone - // work is already on the integration branch before allowing teardown. - // Compare only non-.gsd/ paths — .gsd/ state files diverge normally and - // are auto-resolved during the squash merge. - if (nothingToCommit) { - const numstat = nativeDiffNumstat( - originalBasePath_, - mainBranch, - milestoneBranch, - ); - const codeChanges = numstat.filter( - (entry) => !entry.path.startsWith(".gsd/"), - ); - if (codeChanges.length > 0) { - // Milestone has unanchored code changes — abort teardown. - process.chdir(previousCwd); - throw new GSDError( - SF_GIT_ERROR, - `Squash merge produced nothing to commit but milestone branch "${milestoneBranch}" ` + - `has ${codeChanges.length} code file(s) not on "${mainBranch}". ` + - `Aborting worktree teardown to prevent data loss.`, - ); - } - } - - // 9c. Detect whether any non-.gsd/ code files were actually merged (#1906). - // When a milestone only produced .gsd/ metadata (summaries, roadmaps) but no - // real code, the user sees "milestone complete" but nothing changed in their - // codebase. Surface this so the caller can warn the user. - let codeFilesChanged = false; - if (!nothingToCommit) { - try { - const mergedFiles = nativeDiffNumstat( - originalBasePath_, - "HEAD~1", - "HEAD", - ); - codeFilesChanged = mergedFiles.some( - (entry) => !entry.path.startsWith(".gsd/"), - ); - } catch (e) { - // If HEAD~1 doesn't exist (first commit), assume code was changed - logWarning("worktree", `diff numstat failed (assuming code changed): ${(e as Error).message}`); - codeFilesChanged = true; - } - } - - // 10. Auto-push if enabled - let pushed = false; - if (prefs.auto_push === true && !nothingToCommit) { - const remote = prefs.remote ?? "origin"; - try { - execFileSync("git", ["push", remote, mainBranch], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - pushed = true; - } catch (err) { - // Push failure is non-fatal - logWarning("worktree", `git push failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 9b. Auto-create PR if enabled (#2302: no longer gated on pushed/auto_push) - let prCreated = false; - if (prefs.auto_pr === true && !nothingToCommit) { - const remote = prefs.remote ?? "origin"; - const prTarget = prefs.pr_target_branch ?? mainBranch; - try { - // Push the milestone branch to remote first - execFileSync("git", ["push", remote, milestoneBranch], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - // Create PR via gh CLI with explicit --head and --base (#2302) - execFileSync("gh", [ - "pr", "create", "--draft", - "--base", prTarget, - "--head", milestoneBranch, - "--title", `Milestone ${milestoneId} complete`, - "--body", "Auto-created by SF on milestone completion.", - ], { - cwd: originalBasePath_, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - prCreated = true; - } catch (err) { - // PR creation failure is non-fatal — gh may not be installed or authenticated - logWarning("worktree", `PR creation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 11. Guard removed — step 9b (#1792) now handles this with a smarter check: - // throws only when the milestone has unanchored code changes, passes - // through when the code is genuinely already on the integration branch. - - // 11a. Pre-teardown safety net (#1853): if the worktree still has uncommitted - // changes (e.g. nativeHasChanges cache returned stale false, or auto-commit - // silently failed), force one final commit so code is not destroyed by - // `git worktree remove --force`. - // - // Guard: only run when worktreeCwd is on the milestone branch (#2929). - // In parallel mode or branch-mode merges, worktreeCwd may be the project - // root on the integration branch. Committing dirty state there would - // capture unrelated files from other milestones. - if (existsSync(worktreeCwd)) { - let preTeardownBranch: string | null = null; - try { - preTeardownBranch = nativeGetCurrentBranch(worktreeCwd); - } catch (err) { - debugLog("mergeMilestoneToMain", { phase: "pre-teardown-branch-detect-failed", error: String(err) }); - } - const isOnMilestoneBranch = preTeardownBranch === milestoneBranch; - - if (isOnMilestoneBranch) { - try { - const dirtyCheck = nativeWorkingTreeStatus(worktreeCwd); - if (dirtyCheck) { - debugLog("mergeMilestoneToMain", { - phase: "pre-teardown-dirty", - worktreeCwd, - status: dirtyCheck.slice(0, 200), - }); - nativeAddAllWithExclusions(worktreeCwd, RUNTIME_EXCLUSION_PATHS); - nativeCommit(worktreeCwd, "chore: pre-teardown auto-commit of uncommitted worktree changes"); - } - } catch (e) { - debugLog("mergeMilestoneToMain", { - phase: "pre-teardown-commit-error", - error: String(e), - }); - } - } - } - - // 12. Remove worktree directory first (must happen before branch deletion) - try { - removeWorktree(originalBasePath_, milestoneId, { - branch: milestoneBranch, - deleteBranch: false, - }); - } catch (err) { - // Best-effort -- worktree dir may already be gone - logWarning("worktree", `worktree removal failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 13. Delete milestone branch (after worktree removal so ref is unlocked) - try { - nativeBranchDelete(originalBasePath_, milestoneBranch); - } catch (err) { - // Best-effort - logWarning("worktree", `git branch-delete failed: ${err instanceof Error ? err.message : String(err)}`); - } - - // 14. Clear module state - originalBase = null; - nudgeGitBranchCache(previousCwd); - - return { commitMessage, pushed, prCreated, codeFilesChanged }; -} diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts deleted file mode 100644 index 29bcd456f..000000000 --- a/src/resources/extensions/gsd/auto.ts +++ /dev/null @@ -1,1789 +0,0 @@ -/** - * SF Auto Mode — Fresh Session Per Unit - * - * State machine driven by .gsd/ files on disk. Each "unit" of work - * (plan slice, execute task, complete slice) gets a fresh session via - * the stashed ctx.newSession() pattern. - * - * The extension reads disk state after each agent_end, determines the - * next unit type, creates a fresh session, and injects a focused prompt - * telling the LLM which files to read and what to do. - */ - -import type { - ExtensionAPI, - ExtensionContext, - ExtensionCommandContext, -} from "@sf-run/pi-coding-agent"; - -import { deriveState } from "./state.js"; -import { parseUnitId } from "./unit-id.js"; -import type { GSDState } from "./types.js"; -import { - assessInterruptedSession, - readPausedSessionMetadata, - type InterruptedSessionAssessment, -} from "./interrupted-session.js"; -import { getManifestStatus } from "./files.js"; -export { inlinePriorMilestoneSummary } from "./files.js"; -import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; -import { - gsdRoot, - resolveMilestoneFile, - resolveSliceFile, - resolveSlicePath, - resolveMilestonePath, - resolveDir, - resolveTasksDir, - resolveTaskFile, - milestonesDir, - buildTaskFileName, -} from "./paths.js"; -import { invalidateAllCaches } from "./cache.js"; -import { clearActivityLogState } from "./activity-log.js"; -import { - synthesizeCrashRecovery, - getDeepDiagnostic, - readActiveMilestoneId, -} from "./session-forensics.js"; -import { - writeLock, - clearLock, - readCrashLock, - isLockProcessAlive, - formatCrashInfo, - emitCrashRecoveredUnitEnd, -} from "./crash-recovery.js"; -import { - acquireSessionLock, - getSessionLockStatus, - releaseSessionLock, - updateSessionLock, -} from "./session-lock.js"; -import type { SessionLockStatus } from "./session-lock.js"; -import { - resolveAutoSupervisorConfig, - loadEffectiveGSDPreferences, - getIsolationMode, -} from "./preferences.js"; -import { sendDesktopNotification } from "./notifications.js"; -import type { GSDPreferences } from "./preferences.js"; -import { - type BudgetAlertLevel, - getBudgetAlertLevel, - getNewBudgetAlertLevel, - getBudgetEnforcementAction, -} from "./auto-budget.js"; -import { - markToolStart as _markToolStart, - markToolEnd as _markToolEnd, - getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs, - getInFlightToolCount, - getOldestInFlightToolStart, - hasInteractiveToolInFlight, - clearInFlightTools, - isToolInvocationError, - isQueuedUserMessageSkip, -} from "./auto-tool-tracking.js"; -import { closeoutUnit } from "./auto-unit-closeout.js"; -import { recoverTimedOutUnit } from "./auto-timeout-recovery.js"; -import { selectAndApplyModel, resolveModelId } from "./auto-model-selection.js"; -import { resetRoutingHistory, recordOutcome } from "./routing-history.js"; -import { - checkPostUnitHooks, - getActiveHook, - resetHookState, - isRetryPending, - consumeRetryTrigger, - runPreDispatchHooks, - persistHookState, - restoreHookState, - clearPersistedHookState, -} from "./post-unit-hooks.js"; -import { runGSDDoctor, rebuildState } from "./doctor.js"; -import { - preDispatchHealthGate, - recordHealthSnapshot, - checkHealEscalation, - resetProactiveHealing, - setLevelChangeCallback, - formatHealthSummary, - getConsecutiveErrorUnits, -} from "./doctor-proactive.js"; -import { clearSkillSnapshot } from "./skill-discovery.js"; -import { - captureAvailableSkills, - resetSkillTelemetry, -} from "./skill-telemetry.js"; -import { getRtkSessionSavings } from "../shared/rtk-session-stats.js"; -import { deactivateGSD } from "../shared/gsd-phase-state.js"; -import { - initMetrics, - resetMetrics, - getLedger, - getProjectTotals, - formatCost, - formatTokenCount, -} from "./metrics.js"; -import { setLogBasePath, logWarning, logError } from "./workflow-logger.js"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; -import { atomicWriteSync } from "./atomic-write.js"; -import { - autoCommitCurrentBranch, - captureIntegrationBranch, - detectWorktreeName, - getCurrentBranch, - getMainBranch, - MergeConflictError, - parseSliceBranch, - setActiveMilestoneId, -} from "./worktree.js"; -import { GitServiceImpl } from "./git-service.js"; -import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; -import { - createAutoWorktree, - enterAutoWorktree, - teardownAutoWorktree, - isInAutoWorktree, - getAutoWorktreePath, - getAutoWorktreeOriginalBase, - mergeMilestoneToMain, - autoWorktreeBranch, - syncWorktreeStateBack, - syncProjectRootToWorktree, - syncStateToProjectRoot, - readResourceVersion, - checkResourcesStale, - escapeStaleWorktree, -} from "./auto-worktree.js"; -import { pruneQueueOrder } from "./queue-order.js"; - -import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js"; -import { - buildLoopRemediationSteps, - reconcileMergeState, -} from "./auto-recovery.js"; -import { resolveDispatch, DISPATCH_RULES } from "./auto-dispatch.js"; -import { getErrorMessage } from "./error-utils.js"; -import { initRegistry, convertDispatchRules } from "./rule-registry.js"; -import { emitJournalEvent as _emitJournalEvent, type JournalEntry } from "./journal.js"; -import { - type AutoDashboardData, - updateProgressWidget as _updateProgressWidget, - updateSliceProgressCache, - clearSliceProgressCache, - describeNextUnit as _describeNextUnit, - unitVerb, - formatAutoElapsed as _formatAutoElapsed, - formatWidgetTokens, - hideFooter, - type WidgetStateAccessors, -} from "./auto-dashboard.js"; -import { - registerSigtermHandler as _registerSigtermHandler, - deregisterSigtermHandler as _deregisterSigtermHandler, - detectWorkingTreeActivity, -} from "./auto-supervisor.js"; -import { isDbAvailable, getMilestone } from "./gsd-db.js"; -import { countPendingCaptures } from "./captures.js"; -import { clearCmuxSidebar, logCmuxEvent, syncCmuxSidebar } from "../cmux/index.js"; - -// ── Extracted modules ────────────────────────────────────────────────────── -import { startUnitSupervision } from "./auto-timers.js"; -import { runPostUnitVerification } from "./auto-verification.js"; -import { - autoCommitUnit, - postUnitPreVerification, - postUnitPostVerification, -} from "./auto-post-unit.js"; -import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from "./auto-start.js"; -import { initHealthWidget } from "./health-widget.js"; -import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js"; -import { runAutoLoopWithUok } from "./uok/kernel.js"; -import { resolveUokFlags } from "./uok/flags.js"; -// Slice-level parallelism (#2340) -import { getEligibleSlices } from "./slice-parallel-eligibility.js"; -import { startSliceParallel } from "./slice-parallel-orchestrator.js"; -import { - WorktreeResolver, - type WorktreeResolverDeps, -} from "./worktree-resolver.js"; -import { reorderForCaching } from "./prompt-ordering.js"; - -// ─── Session State ───────────────────────────────────────────────────────── - -import { - AutoSession, - MAX_UNIT_DISPATCHES, - STUB_RECOVERY_THRESHOLD, - MAX_LIFETIME_DISPATCHES, - NEW_SESSION_TIMEOUT_MS, -} from "./auto/session.js"; -import type { - CurrentUnit, - UnitRouting, - StartModel, -} from "./auto/session.js"; -export { - MAX_UNIT_DISPATCHES, - STUB_RECOVERY_THRESHOLD, - MAX_LIFETIME_DISPATCHES, - NEW_SESSION_TIMEOUT_MS, -} from "./auto/session.js"; -export type { - CurrentUnit, - UnitRouting, - StartModel, -} from "./auto/session.js"; - -// ── ENCAPSULATION INVARIANT ───────────────────────────────────────────────── -// ALL mutable auto-mode state lives in the AutoSession class (auto/session.ts). -// This file must NOT declare module-level `let` or `var` variables for state. -// The single `s` instance below is the only mutable module-level binding. -// -// When adding features or fixing bugs: -// - New mutable state → add a property to AutoSession, not a module-level variable -// - New constants → module-level `const` is fine (immutable) -// - New state that needs reset on stopAuto → add to AutoSession.reset() -// -// Tests in auto-session-encapsulation.test.ts enforce this invariant. -// ───────────────────────────────────────────────────────────────────────────── -const s = new AutoSession(); - -/** Throttle STATE.md rebuilds — at most once per 30 seconds */ -const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; - -function captureProjectRootEnv(projectRoot: string): void { - if (!s.projectRootEnvCaptured) { - s.hadProjectRootEnv = Object.prototype.hasOwnProperty.call(process.env, "SF_PROJECT_ROOT"); - s.previousProjectRootEnv = process.env.SF_PROJECT_ROOT ?? null; - s.projectRootEnvCaptured = true; - } - process.env.SF_PROJECT_ROOT = projectRoot; -} - -function restoreProjectRootEnv(): void { - if (!s.projectRootEnvCaptured) return; - - if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) { - process.env.SF_PROJECT_ROOT = s.previousProjectRootEnv; - } else { - delete process.env.SF_PROJECT_ROOT; - } - - s.previousProjectRootEnv = null; - s.hadProjectRootEnv = false; - s.projectRootEnvCaptured = false; -} - -function captureMilestoneLockEnv(milestoneId: string | null): void { - if (!s.milestoneLockEnvCaptured) { - s.hadMilestoneLockEnv = Object.prototype.hasOwnProperty.call(process.env, "SF_MILESTONE_LOCK"); - s.previousMilestoneLockEnv = process.env.SF_MILESTONE_LOCK ?? null; - s.milestoneLockEnvCaptured = true; - } - - if (milestoneId) { - process.env.SF_MILESTONE_LOCK = milestoneId; - } else { - delete process.env.SF_MILESTONE_LOCK; - } -} - -function restoreMilestoneLockEnv(): void { - if (!s.milestoneLockEnvCaptured) return; - - if (s.hadMilestoneLockEnv && s.previousMilestoneLockEnv !== null) { - process.env.SF_MILESTONE_LOCK = s.previousMilestoneLockEnv; - } else { - delete process.env.SF_MILESTONE_LOCK; - } - - s.previousMilestoneLockEnv = null; - s.hadMilestoneLockEnv = false; - s.milestoneLockEnvCaptured = false; -} - -export function startAutoDetached( - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - base: string, - verboseMode: boolean, - options?: { - step?: boolean; - interrupted?: InterruptedSessionAssessment; - milestoneLock?: string | null; - }, -): void { - void startAuto(ctx, pi, base, verboseMode, options).catch((err) => { - const message = getErrorMessage(err); - ctx.ui.notify(`Auto-start failed: ${message}`, "error"); - logWarning("engine", `auto start error: ${message}`, { file: "auto.ts" }); - debugLog("auto-start-failed", { error: message }); - }); -} - -export function shouldUseWorktreeIsolation(): boolean { - const prefs = loadEffectiveGSDPreferences()?.preferences?.git; - if (prefs?.isolation === "worktree") return true; - // Default is false — worktree isolation requires explicit opt-in - return false; -} - -/** Crash recovery prompt — set by startAuto, consumed by the main loop */ - -/** Pending verification retry — set when gate fails with retries remaining, consumed by autoLoop */ - -/** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */ - -/** Session file path captured at pause — used to synthesize recovery briefing on resume */ - -/** Dashboard tracking */ - -/** Track dynamic routing decision for the current unit (for metrics) */ - -/** Queue of quick-task captures awaiting dispatch after triage resolution */ - -/** - * Model captured at auto-mode start. Used to prevent model bleed between - * concurrent SF instances sharing the same global settings.json (#650). - * When preferences don't specify a model for a unit type, this ensures - * the session's original model is re-applied instead of reading from - * the shared global settings (which another instance may have overwritten). - */ - -/** Track current milestone to detect transitions */ - -/** Model the user had selected before auto-mode started */ - -/** Progress-aware timeout supervision */ - -/** Context-pressure continue-here monitor — fires once when context usage >= 70% */ - -/** Prompt character measurement for token savings analysis (R051). */ - -/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */ - -/** - * Tool calls currently being executed — prevents false idle detection during long-running tools. - * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been - * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open). - */ -// Re-export budget utilities for external consumers -export { - getBudgetAlertLevel, - getNewBudgetAlertLevel, - getBudgetEnforcementAction, -} from "./auto-budget.js"; - -/** Wrapper: register SIGTERM handler and store reference. */ -function registerSigtermHandler(currentBasePath: string): void { - s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler); -} - -/** Wrapper: deregister SIGTERM handler and clear reference. */ -function deregisterSigtermHandler(): void { - _deregisterSigtermHandler(s.sigtermHandler); - s.sigtermHandler = null; -} - -export { type AutoDashboardData } from "./auto-dashboard.js"; - -export function getAutoDashboardData(): AutoDashboardData { - const ledger = getLedger(); - const totals = ledger ? getProjectTotals(ledger.units) : null; - const sessionId = s.cmdCtx?.sessionManager?.getSessionId?.() ?? null; - const rtkSavings = sessionId && s.basePath - ? getRtkSessionSavings(s.basePath, sessionId) - : null; - const rtkEnabled = loadEffectiveGSDPreferences()?.preferences.experimental?.rtk === true; - // Pending capture count — lazy check, non-fatal - let pendingCaptureCount = 0; - try { - if (s.basePath) { - pendingCaptureCount = countPendingCaptures(s.basePath); - } - } catch (err) { - // Non-fatal — captures module may not be loaded - logWarning("engine", `capture count failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - return { - active: s.active, - paused: s.paused, - stepMode: s.stepMode, - startTime: s.autoStartTime, - elapsed: s.active || s.paused - ? (s.autoStartTime > 0 ? Date.now() - s.autoStartTime : 0) - : 0, - currentUnit: s.currentUnit ? { ...s.currentUnit } : null, - basePath: s.basePath, - totalCost: totals?.cost ?? 0, - totalTokens: totals?.tokens.total ?? 0, - pendingCaptureCount, - rtkSavings, - rtkEnabled, - }; -} - -// ─── Public API ─────────────────────────────────────────────────────────────── - -export function isAutoActive(): boolean { - return s.active; -} - -export function isAutoPaused(): boolean { - return s.paused; -} - -export function setActiveEngineId(id: string | null): void { - s.activeEngineId = id; -} - -export function getActiveEngineId(): string | null { - return s.activeEngineId; -} - -export function setActiveRunDir(runDir: string | null): void { - s.activeRunDir = runDir; -} - -export function getActiveRunDir(): string | null { - return s.activeRunDir; -} - -/** - * Return the model captured at auto-mode start for this session. - * Used by error-recovery to fall back to the session's own model - * instead of reading (potentially stale) preferences from disk (#1065). - */ -export function getAutoModeStartModel(): { - provider: string; - id: string; -} | null { - return s.autoModeStartModel; -} - -// Tool tracking — delegates to auto-tool-tracking.ts -export function markToolStart(toolCallId: string, toolName?: string): void { - _markToolStart(toolCallId, s.active, toolName); -} - -export function markToolEnd(toolCallId: string): void { - _markToolEnd(toolCallId); -} - -/** - * Record a tool invocation error on the current session (#2883). - * Called from tool_execution_end when a SF tool fails with isError. - * Only stores the error if it matches the tool-invocation-error pattern - * (malformed/truncated JSON), not normal business-logic errors. - */ -export function recordToolInvocationError(toolName: string, errorMsg: string): void { - if (!s.active) return; - if (isToolInvocationError(errorMsg) || isQueuedUserMessageSkip(errorMsg)) { - s.lastToolInvocationError = `${toolName}: ${errorMsg}`; - } -} - -export function getOldestInFlightToolAgeMs(): number { - return _getOldestInFlightToolAgeMs(); -} - -/** - * Return the base path to use for the auto.lock file. - * Always uses the original project root (not the worktree) so that - * a second terminal can discover and stop a running auto-mode session. - * - * Delegates to AutoSession.lockBasePath — the single source of truth. - */ -function lockBase(): string { - return s.lockBasePath; -} - -/** - * Attempt to stop a running auto-mode session from a different process. - * Reads the lock file at the project root, checks if the PID is alive, - * and sends SIGTERM to gracefully stop it. - * - * Returns true if a remote session was found and signaled, false otherwise. - */ -export function stopAutoRemote(projectRoot: string): { - found: boolean; - pid?: number; - error?: string; -} { - const lock = readCrashLock(projectRoot); - if (!lock) return { found: false }; - - // Never SIGTERM ourselves — a stale lock with our own PID is not a remote - // session, it is leftover from a prior loop exit in this process. (#2730) - if (lock.pid === process.pid) { - clearLock(projectRoot); - return { found: false }; - } - - if (!isLockProcessAlive(lock)) { - // Stale lock — clean it up - clearLock(projectRoot); - return { found: false }; - } - - // Send SIGTERM — the auto-mode process has a handler that clears the lock and exits - try { - process.kill(lock.pid, "SIGTERM"); - return { found: true, pid: lock.pid }; - } catch (err) { - return { found: false, error: (err as Error).message }; - } -} - -/** - * Check if a remote auto-mode session is running (from a different process). - * Reads the crash lock, checks PID liveness, and returns session details. - * Used by the guard in commands.ts to prevent bare /gsd, /gsd next, and - * /gsd auto from stealing the session lock. - */ -export function checkRemoteAutoSession(projectRoot: string): { - running: boolean; - pid?: number; - unitType?: string; - unitId?: string; - startedAt?: string; -} { - const lock = readCrashLock(projectRoot); - if (!lock) return { running: false }; - - // Our own PID is not a "remote" session — it is a stale lock left by this - // process (e.g. after step-mode exit without full cleanup). (#2730) - if (lock.pid === process.pid) return { running: false }; - - if (!isLockProcessAlive(lock)) { - // Stale lock from a dead process — not a live remote session - return { running: false }; - } - - return { - running: true, - pid: lock.pid, - unitType: lock.unitType, - unitId: lock.unitId, - startedAt: lock.startedAt, - }; -} - -export function isStepMode(): boolean { - return s.stepMode; -} - -function clearUnitTimeout(): void { - if (s.unitTimeoutHandle) { - clearTimeout(s.unitTimeoutHandle); - s.unitTimeoutHandle = null; - } - if (s.wrapupWarningHandle) { - clearTimeout(s.wrapupWarningHandle); - s.wrapupWarningHandle = null; - } - if (s.idleWatchdogHandle) { - clearInterval(s.idleWatchdogHandle); - s.idleWatchdogHandle = null; - } - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - clearInFlightTools(); -} - -/** Build snapshot metric opts. */ -function buildSnapshotOpts( - _unitType: string, - _unitId: string, -): { - autoSessionKey?: string; - continueHereFired?: boolean; - promptCharCount?: number; - baselineCharCount?: number; - traceId?: string; - turnId?: string; - gitAction?: "commit" | "snapshot" | "status-only"; - gitPush?: boolean; - gitStatus?: "ok" | "failed"; - gitError?: string; -} & Record { - const prefs = loadEffectiveGSDPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - return { - ...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}), - promptCharCount: s.lastPromptCharCount, - baselineCharCount: s.lastBaselineCharCount, - traceId: s.currentTraceId ?? undefined, - turnId: s.currentTurnId ?? undefined, - ...(uokFlags.gitops - ? { - gitAction: uokFlags.gitopsTurnAction, - gitPush: uokFlags.gitopsTurnPush, - gitStatus: s.lastGitActionStatus ?? undefined, - gitError: s.lastGitActionFailure ?? undefined, - } - : {}), - ...(s.currentUnitRouting ?? {}), - }; -} - -function handleLostSessionLock( - ctx?: ExtensionContext, - lockStatus?: SessionLockStatus, -): void { - debugLog("session-lock-lost", { - lockBase: lockBase(), - reason: lockStatus?.failureReason, - existingPid: lockStatus?.existingPid, - expectedPid: lockStatus?.expectedPid, - }); - s.active = false; - s.paused = false; - deactivateGSD(); - clearUnitTimeout(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - deregisterSigtermHandler(); - clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); - const base = lockBase(); - const lockFilePath = base ? join(gsdRoot(base), "auto.lock") : "unknown"; - const recoverySuggestion = "\nTo recover, run: gsd doctor --fix"; - const message = - lockStatus?.failureReason === "pid-mismatch" - ? lockStatus.existingPid - ? `Session lock (${lockFilePath}) moved to PID ${lockStatus.existingPid} — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` - : `Session lock (${lockFilePath}) moved to a different process — another SF process appears to have taken over. Stopping gracefully.${recoverySuggestion}` - : lockStatus?.failureReason === "missing-metadata" - ? `Session lock metadata (${lockFilePath}) disappeared, so ownership could not be confirmed. Stopping gracefully.${recoverySuggestion}` - : lockStatus?.failureReason === "compromised" - ? `Session lock (${lockFilePath}) was compromised during heartbeat checks (PID ${process.pid}). This can happen after long event loop stalls during subagent execution.${recoverySuggestion}` - : `Session lock lost (${lockFilePath}). Stopping gracefully.${recoverySuggestion}`; - ctx?.ui.notify( - message, - "error", - ); - ctx?.ui.setStatus("gsd-auto", undefined); - ctx?.ui.setWidget("gsd-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) initHealthWidget(ctx); -} - -/** - * Lightweight cleanup after autoLoop exits via step-wizard break. - * - * Unlike stopAuto (which tears down the entire session), this only clears - * the stale unit state, progress widget, status badge, and restores CWD so - * the dashboard does not show an orphaned timer and the shell is usable. - */ -function cleanupAfterLoopExit(ctx: ExtensionContext): void { - s.currentUnit = null; - s.active = false; - deactivateGSD(); - clearUnitTimeout(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - - // Clear crash lock and release session lock so the next `/gsd next` does - // not see a stale lock with the current PID and treat it as a "remote" - // session (which would cause it to SIGTERM itself). (#2730) - try { - if (lockBase()) clearLock(lockBase()); - if (lockBase()) releaseSessionLock(lockBase()); - } catch (err) { - /* best-effort — mirror stopAuto cleanup */ - logWarning("session", `lock cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - // A transient provider-error pause intentionally leaves the paused badge - // visible so the user still has a resumable auto-mode signal on screen. - if (!s.paused) { - ctx.ui.setStatus("gsd-auto", undefined); - ctx.ui.setWidget("gsd-progress", undefined); - ctx.ui.setFooter(undefined); - initHealthWidget(ctx); - } - - // Restore CWD out of worktree back to original project root - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - try { - process.chdir(s.basePath); - } catch (err) { - /* best-effort */ - logWarning("engine", `chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } -} - -export async function stopAuto( - ctx?: ExtensionContext, - pi?: ExtensionAPI, - reason?: string, -): Promise { - if (!s.active && !s.paused) return; - const loadedPreferences = loadEffectiveGSDPreferences()?.preferences; - const reasonSuffix = reason ? ` — ${reason}` : ""; - - try { - // ── Step 1: Timers and locks ── - try { - clearUnitTimeout(); - if (lockBase()) clearLock(lockBase()); - if (lockBase()) releaseSessionLock(lockBase()); - } catch (e) { - debugLog("stop-cleanup-locks", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 1b: Flush queued follow-up messages (#3512) ── - // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger - // extra LLM turns after stop. Flush them the same way run-unit.ts does. - try { - const cmdCtxAny = s.cmdCtx as Record | null; - if (typeof cmdCtxAny?.clearQueue === "function") { - (cmdCtxAny.clearQueue as () => unknown)(); - } - } catch (e) { - debugLog("stop-cleanup-queue", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 2: Skill state ── - try { - clearSkillSnapshot(); - resetSkillTelemetry(); - } catch (e) { - debugLog("stop-cleanup-skills", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 3: SIGTERM handler ── - try { - deregisterSigtermHandler(); - } catch (e) { - debugLog("stop-cleanup-sigterm", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 4: Auto-worktree exit ── - // When the milestone is complete (has a SUMMARY), merge the worktree branch - // back to main so code isn't stranded on the worktree branch (#2317). - // For incomplete milestones, preserve the branch for later resumption. - // - // Skip if phases.ts already merged this milestone — avoids the double - // mergeAndExit that fails because the branch was already deleted (#2645). - try { - if (s.currentMilestoneId && !s.milestoneMergedInPhases) { - const notifyCtx = ctx - ? { notify: ctx.ui.notify.bind(ctx.ui) } - : { notify: () => {} }; - const resolver = buildResolver(); - - // Check if the milestone is complete. DB status is the authoritative - // signal — only a successful gsd_complete_milestone call flips it to - // "complete" (tools/complete-milestone.ts). SUMMARY file presence is - // NOT sufficient: a blocker placeholder stub or a partial write can - // leave a file behind without the milestone actually being done, - // which previously caused stopAuto to merge a failed milestone and - // emit a misleading metadata-only merge warning (#4175). - // DB-unavailable projects fall back to SUMMARY-file presence. - let milestoneComplete = false; - try { - if (isDbAvailable()) { - const dbRow = getMilestone(s.currentMilestoneId); - milestoneComplete = dbRow?.status === "complete"; - } else { - const summaryPath = resolveMilestoneFile( - s.originalBasePath || s.basePath, - s.currentMilestoneId, - "SUMMARY", - ); - if (!summaryPath) { - // Also check in the worktree path (SUMMARY may not be synced yet) - const wtSummaryPath = resolveMilestoneFile( - s.basePath, - s.currentMilestoneId, - "SUMMARY", - ); - milestoneComplete = wtSummaryPath !== null; - } else { - milestoneComplete = true; - } - } - } catch (err) { - // Non-fatal — fall through to preserveBranch path - logWarning("engine", `milestone summary check failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - if (milestoneComplete) { - // Milestone is complete — merge worktree branch back to main - resolver.mergeAndExit(s.currentMilestoneId, notifyCtx); - } else { - // Milestone still in progress — preserve branch for later resumption - resolver.exitMilestone(s.currentMilestoneId, notifyCtx, { - preserveBranch: true, - }); - } - } - } catch (e) { - debugLog("stop-cleanup-worktree", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 5: Rebuild state while DB is still open (#3599) ── - // rebuildState() calls deriveState() which needs the DB for authoritative - // state. Previously this ran after closeDatabase(), forcing a filesystem - // fallback that could disagree with the DB-backed dispatch decisions — - // a split-brain where dispatch says "blocked" but STATE.md shows work. - if (s.basePath) { - try { - await rebuildState(s.basePath); - } catch (e) { - debugLog("stop-rebuild-state-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - } - - // ── Step 6: DB cleanup ── - if (isDbAvailable()) { - try { - const { closeDatabase } = await import("./gsd-db.js"); - closeDatabase(); - } catch (e) { - debugLog("db-close-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - } - - // ── Step 7: Restore basePath and chdir ── - try { - if (s.originalBasePath) { - s.basePath = s.originalBasePath; - try { - process.chdir(s.basePath); - } catch (err) { - /* best-effort */ - logWarning("engine", `chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } - } catch (e) { - debugLog("stop-cleanup-basepath", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 8: Ledger notification ── - try { - const ledger = getLedger(); - if (ledger && ledger.units.length > 0) { - const totals = getProjectTotals(ledger.units); - ctx?.ui.notify( - `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, - "info", - ); - } else { - ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info"); - } - } catch (e) { - debugLog("stop-cleanup-ledger", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 9: Cmux sidebar / event log ── - try { - clearCmuxSidebar(loadedPreferences); - logCmuxEvent( - loadedPreferences, - `Auto-mode stopped${reasonSuffix || ""}.`, - reason?.startsWith("Blocked:") ? "warning" : "info", - ); - } catch (e) { - debugLog("stop-cleanup-cmux", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 10: Debug summary ── - try { - if (isDebugEnabled()) { - const logPath = writeDebugSummary(); - if (logPath) { - ctx?.ui.notify(`Debug log written → ${logPath}`, "info"); - } - } - } catch (e) { - debugLog("stop-cleanup-debug", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 11: Reset metrics, routing, hooks ── - try { - resetMetrics(); - resetRoutingHistory(); - resetHookState(); - if (s.basePath) clearPersistedHookState(s.basePath); - } catch (e) { - debugLog("stop-cleanup-metrics", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 12: Remove paused-session metadata (#1383) ── - try { - const pausedPath = join(gsdRoot(s.originalBasePath || s.basePath), "runtime", "paused-session.json"); - if (existsSync(pausedPath)) unlinkSync(pausedPath); - } catch (err) { /* non-fatal */ - logWarning("engine", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - // ── Step 13: Restore original model (before reset clears IDs) ── - try { - if (pi && ctx && s.originalModelId && s.originalModelProvider) { - const original = ctx.modelRegistry.find( - s.originalModelProvider, - s.originalModelId, - ); - if (original) await pi.setModel(original); - } - } catch (e) { - debugLog("stop-cleanup-model", { error: e instanceof Error ? e.message : String(e) }); - } - - // ── Step 14: Unblock pending unitPromise (#1799) ── - // resolveAgentEnd unblocks autoLoop's `await unitPromise` so it can see - // s.active === false and exit cleanly. Without this, autoLoop hangs - // forever and the interactive loop is blocked. - try { - resolveAgentEnd({ messages: [] }); - _resetPendingResolve(); - } catch (e) { - debugLog("stop-cleanup-pending-resolve", { error: e instanceof Error ? e.message : String(e) }); - } - } finally { - // ── Critical invariants: these MUST execute regardless of errors ── - // Browser teardown — prevent orphaned Chrome processes across retries (#1733) - try { - const { getBrowser } = await import("../browser-tools/state.js"); - if (getBrowser()) { - const { closeBrowser } = await import("../browser-tools/lifecycle.js"); - await closeBrowser(); - } - } catch (err) { /* non-fatal: browser-tools may not be loaded */ - logWarning("engine", `browser teardown failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - // External cleanup (not covered by session reset) - clearInFlightTools(); - clearSliceProgressCache(); - clearActivityLogState(); - setLevelChangeCallback(null); - resetProactiveHealing(); - - // UI cleanup - ctx?.ui.setStatus("gsd-auto", undefined); - ctx?.ui.setWidget("gsd-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) initHealthWidget(ctx); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - - // Reset all session state in one call - s.reset(); - } -} - -/** - * Pause auto-mode without destroying state. Context is preserved. - * The user can interact with the agent, then `/gsd auto` resumes - * from disk state. Called when the user presses Escape during auto-mode. - */ -export async function pauseAuto( - ctx?: ExtensionContext, - _pi?: ExtensionAPI, - _errorContext?: ErrorContext, -): Promise { - if (!s.active) return; - clearUnitTimeout(); - - // Flush queued follow-up messages (#3512). - // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger - // extra LLM turns after pause. Flush them the same way run-unit.ts does. - try { - const cmdCtxAny = s.cmdCtx as Record | null; - if (typeof cmdCtxAny?.clearQueue === "function") { - (cmdCtxAny.clearQueue as () => unknown)(); - } - } catch (e) { - debugLog("pause-cleanup-queue", { error: e instanceof Error ? e.message : String(e) }); - } - - // Unblock any pending unit promise so the auto-loop is not orphaned. - // Pass errorContext so runUnitPhase can distinguish user-initiated pause - // from provider-error pause and avoid hard-stopping (#2762). - resolveAgentEndCancelled(_errorContext); - - s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; - - // Persist paused-session metadata so resume survives /exit (#1383). - // The fresh-start bootstrap checks for this file and restores worktree context. - try { - const pausedMeta = { - milestoneId: s.currentMilestoneId, - worktreePath: isInAutoWorktree(s.basePath) ? s.basePath : null, - originalBasePath: s.originalBasePath, - stepMode: s.stepMode, - pausedAt: new Date().toISOString(), - sessionFile: s.pausedSessionFile, - unitType: s.currentUnit?.type ?? undefined, - unitId: s.currentUnit?.id ?? undefined, - activeEngineId: s.activeEngineId, - activeRunDir: s.activeRunDir, - autoStartTime: s.autoStartTime, - milestoneLock: s.sessionMilestoneLock ?? undefined, - }; - const runtimeDir = join(gsdRoot(s.originalBasePath || s.basePath), "runtime"); - mkdirSync(runtimeDir, { recursive: true }); - writeFileSync( - join(runtimeDir, "paused-session.json"), - JSON.stringify(pausedMeta, null, 2), - "utf-8", - ); - } catch (err) { - // Non-fatal — resume will still work via full bootstrap, just without worktree context - logWarning("engine", `paused-session file write failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - // Close out the current unit so its runtime record doesn't stay at "dispatched" - if (s.currentUnit && ctx) { - try { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } catch (err) { - // Non-fatal — best-effort closeout on pause - logWarning("engine", `unit closeout on pause failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - s.currentUnit = null; - } - - if (lockBase()) { - releaseSessionLock(lockBase()); - clearLock(lockBase()); - } - - deregisterSigtermHandler(); - - // Unblock pending unitPromise so autoLoop exits cleanly (#1799) - resolveAgentEnd({ messages: [] }); - _resetPendingResolve(); - - s.active = false; - s.paused = true; - deactivateGSD(); - restoreProjectRootEnv(); - restoreMilestoneLockEnv(); - s.pendingVerificationRetry = null; - s.verificationRetryCount.clear(); - ctx?.ui.setStatus("gsd-auto", "paused"); - ctx?.ui.setWidget("gsd-progress", undefined); - ctx?.ui.setFooter(undefined); - if (ctx) initHealthWidget(ctx); - const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto"; - ctx?.ui.notify( - `${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, - "info", - ); -} - -/** - * Build a WorktreeResolverDeps from auto.ts private scope. - * Shared by buildResolver() and buildLoopDeps(). - */ -function buildResolverDeps(): WorktreeResolverDeps { - return { - isInAutoWorktree, - shouldUseWorktreeIsolation, - getIsolationMode, - mergeMilestoneToMain, - syncWorktreeStateBack, - teardownAutoWorktree, - createAutoWorktree, - enterAutoWorktree, - getAutoWorktreePath, - autoCommitCurrentBranch, - getCurrentBranch, - autoWorktreeBranch, - resolveMilestoneFile, - readFileSync: (path: string, encoding: string) => - readFileSync(path, encoding as BufferEncoding), - GitServiceImpl: - GitServiceImpl as unknown as WorktreeResolverDeps["GitServiceImpl"], - loadEffectiveGSDPreferences: - loadEffectiveGSDPreferences as unknown as WorktreeResolverDeps["loadEffectiveGSDPreferences"], - invalidateAllCaches, - captureIntegrationBranch, - }; -} - -/** - * Build a WorktreeResolver wrapping the current session. - * Cheap to construct — it's just a thin wrapper over `s` + deps. - * Used by stopAuto(), resume path, and buildLoopDeps(). - */ -function buildResolver(): WorktreeResolver { - return new WorktreeResolver(s, buildResolverDeps()); -} - -/** - * Build the LoopDeps object from auto.ts private scope. - * This bundles all private functions that autoLoop needs without exporting them. - */ -function buildLoopDeps(): LoopDeps { - // Initialize the unified rule registry with converted dispatch rules. - // Must happen before LoopDeps is assembled so facade functions - // (resolveDispatch, runPreDispatchHooks, etc.) delegate to the registry. - initRegistry(convertDispatchRules(DISPATCH_RULES)); - - return { - lockBase, - buildSnapshotOpts, - stopAuto, - pauseAuto, - clearUnitTimeout, - updateProgressWidget, - syncCmuxSidebar, - logCmuxEvent, - - // State and cache - invalidateAllCaches, - deriveState, - rebuildState, - loadEffectiveGSDPreferences, - - // Pre-dispatch health gate - preDispatchHealthGate, - - // Worktree sync - syncProjectRootToWorktree, - - // Resource version guard - checkResourcesStale, - - // Session lock - validateSessionLock: getSessionLockStatus, - updateSessionLock, - handleLostSessionLock, - - // Milestone transition - sendDesktopNotification, - setActiveMilestoneId, - pruneQueueOrder, - isInAutoWorktree, - shouldUseWorktreeIsolation, - mergeMilestoneToMain, - teardownAutoWorktree, - createAutoWorktree, - captureIntegrationBranch, - getIsolationMode, - getCurrentBranch, - autoWorktreeBranch, - resolveMilestoneFile, - reconcileMergeState, - - // Budget/context/secrets - getLedger, - getProjectTotals, - formatCost, - getBudgetAlertLevel, - getNewBudgetAlertLevel, - getBudgetEnforcementAction, - getManifestStatus, - collectSecretsFromManifest, - - // Dispatch - resolveDispatch, - runPreDispatchHooks, - getPriorSliceCompletionBlocker, - getMainBranch, - // Unit closeout + runtime records - closeoutUnit, - autoCommitUnit, - recordOutcome, - writeLock, - captureAvailableSkills, - ensurePreconditions, - updateSliceProgressCache, - - // Model selection + supervision - selectAndApplyModel, - resolveModelId, - startUnitSupervision, - - // Prompt helpers - getDeepDiagnostic: (basePath: string) => { - const mid = readActiveMilestoneId(basePath); - const wtPath = mid ? getAutoWorktreePath(basePath, mid) : undefined; - return getDeepDiagnostic(basePath, wtPath ?? undefined); - }, - isDbAvailable, - reorderForCaching, - - // Filesystem - existsSync, - readFileSync: (path: string, encoding: string) => - readFileSync(path, encoding as BufferEncoding), - atomicWriteSync, - - // Git - GitServiceImpl: GitServiceImpl as unknown as LoopDeps["GitServiceImpl"], - - // WorktreeResolver - resolver: buildResolver(), - - // Post-unit processing - postUnitPreVerification, - runPostUnitVerification, - postUnitPostVerification, - - // Session manager - getSessionFile: (ctx: ExtensionContext) => { - try { - return ctx.sessionManager?.getSessionFile() ?? ""; - } catch { - return ""; - } - }, - - // Journal - emitJournalEvent: (entry: JournalEntry) => _emitJournalEvent(s.basePath, entry), - } as unknown as LoopDeps; -} - -export async function startAuto( - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - base: string, - verboseMode: boolean, - options?: { - step?: boolean; - interrupted?: InterruptedSessionAssessment; - milestoneLock?: string | null; - }, -): Promise { - if (s.active) { - debugLog("startAuto", { phase: "already-active", skipping: true }); - return; - } - - const requestedStepMode = options?.step ?? false; - const interruptedAssessment = options?.interrupted ?? null; - if (options?.milestoneLock !== undefined) { - s.sessionMilestoneLock = options.milestoneLock ?? null; - } - if (s.sessionMilestoneLock) { - captureMilestoneLockEnv(s.sessionMilestoneLock); - } - - // Escape stale worktree cwd from a previous milestone (#608). - base = escapeStaleWorktree(base); - - const freshStartAssessment = interruptedAssessment - ?? await assessInterruptedSession(base); - - if (freshStartAssessment.classification === "running") { - const pid = freshStartAssessment.lock?.pid; - ctx.ui.notify( - pid - ? `Another auto-mode session (PID ${pid}) appears to be running.\nStop it with \`kill ${pid}\` before starting a new session.` - : "Another auto-mode session appears to be running.", - "error", - ); - return; - } - - // If resuming from paused state, just re-activate and dispatch next unit. - // Check persisted paused-session first (#1383) — survives /exit. - if (!s.paused) { - try { - const meta = freshStartAssessment.pausedSession ?? readPausedSessionMetadata(base); - const pausedPath = join(gsdRoot(base), "runtime", "paused-session.json"); - if (meta?.activeEngineId && meta.activeEngineId !== "dev") { - // Custom workflow resume — restore engine state - s.activeEngineId = meta.activeEngineId; - s.activeRunDir = meta.activeRunDir ?? null; - s.originalBasePath = meta.originalBasePath || base; - s.stepMode = meta.stepMode ?? requestedStepMode; - s.autoStartTime = meta.autoStartTime || Date.now(); - s.sessionMilestoneLock = meta.milestoneLock ?? null; - s.paused = true; - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } - ctx.ui.notify( - `Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, - "info", - ); - } else if (meta?.milestoneId) { - const shouldResumePausedSession = - freshStartAssessment.classification === "recoverable" - && ( - freshStartAssessment.hasResumableDiskState - || !!freshStartAssessment.recoveryPrompt - || !!freshStartAssessment.lock - ); - if (shouldResumePausedSession) { - // Validate the milestone still exists and isn't already complete (#1664). - const mDir = resolveMilestonePath(base, meta.milestoneId); - const summaryFile = resolveMilestoneFile(base, meta.milestoneId, "SUMMARY"); - if (!mDir || summaryFile) { - try { unlinkSync(pausedPath); } catch (err) { - logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - ctx.ui.notify( - `Paused milestone ${meta.milestoneId} is ${!mDir ? "missing" : "already complete"}. Starting fresh.`, - "info", - ); - } else { - s.currentMilestoneId = meta.milestoneId; - s.originalBasePath = meta.originalBasePath || base; - s.stepMode = meta.stepMode ?? requestedStepMode; - s.pausedSessionFile = meta.sessionFile ?? null; - s.pausedUnitType = meta.unitType ?? null; - s.pausedUnitId = meta.unitId ?? null; - s.autoStartTime = meta.autoStartTime || Date.now(); - s.sessionMilestoneLock = meta.milestoneLock ?? null; - s.paused = true; - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } - ctx.ui.notify( - `Resuming paused session for ${meta.milestoneId}${meta.worktreePath && existsSync(meta.worktreePath) ? ` (worktree)` : ""}.`, - "info", - ); - } - } else if (existsSync(pausedPath)) { - try { unlinkSync(pausedPath); } catch (e) { logWarning("session", `stale pause file cleanup failed: ${e instanceof Error ? e.message : String(e)}`, { file: "auto.ts" }); } - } - } - } catch (err) { - // Malformed or missing — proceed with fresh bootstrap - logWarning("session", `paused-session restore failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - // Guard against zero/missing autoStartTime after resume (#3585) - if (!s.autoStartTime || s.autoStartTime <= 0) s.autoStartTime = Date.now(); - } - - if (s.sessionMilestoneLock) { - captureMilestoneLockEnv(s.sessionMilestoneLock); - } - - if (!s.paused) { - s.stepMode = requestedStepMode; - } - - if (freshStartAssessment.lock) { - // Emit a synthetic unit-end for any unit-start that has no closing event. - // This closes the journal gap reported in #3348 where the worker wrote side - // effects (SUMMARY.md, DB updates) but died before emitting unit-end. - emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock); - clearLock(base); - } - - if (!s.paused) { - s.pendingCrashRecovery = - freshStartAssessment.classification === "recoverable" - ? freshStartAssessment.recoveryPrompt - : null; - - if (freshStartAssessment.classification === "recoverable" && freshStartAssessment.lock) { - const info = formatCrashInfo(freshStartAssessment.lock); - if (freshStartAssessment.recoveryToolCallCount > 0) { - ctx.ui.notify( - `${info}\nRecovered ${freshStartAssessment.recoveryToolCallCount} tool calls from crashed session. Resuming with full context.`, - "warning", - ); - } else if (freshStartAssessment.hasResumableDiskState) { - ctx.ui.notify(`${info}\nResuming from disk state.`, "warning"); - } - } - } - - if (s.paused) { - const resumeLock = acquireSessionLock(base); - if (!resumeLock.acquired) { - // Reset paused state so isAutoPaused() doesn't stick true after lock failure. - // Pause file is preserved on disk for retry — not deleted. - s.paused = false; - ctx.ui.notify(`Cannot resume: ${resumeLock.reason}`, "error"); - return; - } - - // Lock acquired — now safe to delete the pause file - if (s.pausedSessionFile) { - try { unlinkSync(s.pausedSessionFile); } catch (err) { - logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - s.pausedSessionFile = null; - } - - s.paused = false; - s.active = true; - s.verbose = verboseMode; - s.stepMode = requestedStepMode; - s.cmdCtx = ctx; - s.basePath = base; - // Ensure the workflow-logger audit log is pinned to the project root - // even when auto-mode is entered via a path that bypasses the - // bootstrap/dynamic-tools ensureDbOpen() → setLogBasePath() chain - // (e.g. /clear resume, hot-reload). - setLogBasePath(base); - s.unitDispatchCount.clear(); - s.unitLifetimeDispatches.clear(); - if (!getLedger()) initMetrics(base); - if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); - - // Re-register health level notification callback lost across process restart - setLevelChangeCallback((_from, to, summary) => { - const level = to === "red" ? "error" : to === "yellow" ? "warning" : "info"; - ctx.ui.notify(summary, level as "info" | "warning" | "error"); - }); - - // ── Auto-worktree: re-enter worktree on resume ── - if ( - s.currentMilestoneId && - shouldUseWorktreeIsolation() && - s.originalBasePath && - !isInAutoWorktree(s.basePath) && - !detectWorktreeName(s.basePath) && - !detectWorktreeName(s.originalBasePath) - ) { - buildResolver().enterMilestone(s.currentMilestoneId, { - notify: ctx.ui.notify.bind(ctx.ui), - }); - } - - registerSigtermHandler(lockBase()); - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.setFooter(hideFooter); - ctx.ui.notify( - s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", - "info", - ); - restoreHookState(s.basePath); - // Re-sync managed resources on resume so long-lived auto sessions pick up - // bundled extension updates before resume-time verification/state logic runs. - // SF_PKG_ROOT is set by loader.ts and points to the sf-run package root. - // The relative import ("../../../resource-loader.js") only works from the source - // tree; deployed extensions live at ~/.gsd/agent/extensions/gsd/ where the - // relative path resolves to ~/.gsd/agent/resource-loader.js which doesn't exist. - // Using SF_PKG_ROOT constructs a correct absolute path in both contexts (#3949). - const agentDir = process.env.SF_CODING_AGENT_DIR || join(process.env.SF_HOME || homedir(), ".gsd", "agent"); - const pkgRoot = process.env.SF_PKG_ROOT; - const resourceLoaderPath = pkgRoot - ? pathToFileURL(join(pkgRoot, "dist", "resource-loader.js")).href - : new URL("../../../resource-loader.js", import.meta.url).href; - const { initResources } = await import(resourceLoaderPath); - initResources(agentDir); - // Open the project DB before rebuild/derive so resume uses DB-backed - // state instead of falling back to stale markdown parsing (#2940). - await openProjectDbIfPresent(s.basePath); - try { - await rebuildState(s.basePath); - syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); - } catch (e) { - debugLog("resume-rebuild-state-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - try { - const report = await runGSDDoctor(s.basePath, { fix: true }); - if (report.fixesApplied.length > 0) { - ctx.ui.notify( - `Resume: applied ${report.fixesApplied.length} fix(es) to state.`, - "info", - ); - } - } catch (e) { - debugLog("resume-doctor-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - invalidateAllCaches(); - - if (s.pausedSessionFile) { - const activityDir = join(gsdRoot(s.basePath), "activity"); - const recovery = synthesizeCrashRecovery( - s.basePath, - s.currentUnit?.type ?? s.pausedUnitType ?? "unknown", - s.currentUnit?.id ?? s.pausedUnitId ?? "unknown", - s.pausedSessionFile ?? undefined, - activityDir, - ); - if (recovery && recovery.trace.toolCallCount > 0) { - s.pendingCrashRecovery = recovery.prompt; - ctx.ui.notify( - `Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, - "info", - ); - } - s.pausedSessionFile = null; - } - - updateSessionLock( - lockBase(), - "resuming", - s.currentMilestoneId ?? "unknown", - ); - writeLock( - lockBase(), - "resuming", - s.currentMilestoneId ?? "unknown", - ); - logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); - - captureProjectRootEnv(s.originalBasePath || s.basePath); - await runAutoLoopWithUok({ - ctx, - pi, - s, - deps: buildLoopDeps(), - runLegacyLoop: autoLoop, - }); - cleanupAfterLoopExit(ctx); - return; - } - - // ── Fresh start path — delegated to auto-start.ts ── - const bootstrapDeps: BootstrapDeps = { - shouldUseWorktreeIsolation, - registerSigtermHandler, - lockBase, - buildResolver, - }; - - const ready = await bootstrapAutoSession( - s, - ctx, - pi, - base, - verboseMode, - requestedStepMode, - bootstrapDeps, - freshStartAssessment, - ); - if (!ready) return; - - captureProjectRootEnv(s.originalBasePath || s.basePath); - try { - syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); - } catch (err) { - // Best-effort only — sidebar sync must never block auto-mode startup - logWarning("engine", `cmux sync failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); - - // Dispatch the first unit - await runAutoLoopWithUok({ - ctx, - pi, - s, - deps: buildLoopDeps(), - runLegacyLoop: autoLoop, - }); - cleanupAfterLoopExit(ctx); -} - -// ─── Agent End Handler ──────────────────────────────────────────────────────── - -/** - * Deprecated thin wrapper — kept as export for backward compatibility. - * The actual agent_end processing now happens via resolveAgentEnd() in auto-loop.ts, - * which is called directly from index.ts. The autoLoop() while loop handles all - * post-unit processing (verification, hooks, dispatch) that this function used to do. - * - * If called by straggler code, it simply resolves the pending promise so the loop - * can continue. - */ -export async function handleAgentEnd( - ctx: ExtensionContext, - pi: ExtensionAPI, -): Promise { - if (!s.active || !s.cmdCtx) { - // Even when inactive, resolve any pending promise so the loop is unblocked. - resolveAgentEndCancelled(); - return; - } - clearUnitTimeout(); - resolveAgentEnd({ messages: [] }); -} -// describeNextUnit is imported from auto-dashboard.ts and re-exported -export { describeNextUnit } from "./auto-dashboard.js"; - -/** Thin wrapper: delegates to auto-dashboard.ts, passing state accessors. */ -function updateProgressWidget( - ctx: ExtensionContext, - unitType: string, - unitId: string, - state: GSDState, -): void { - const badge = s.currentUnitRouting?.tier - ? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? - undefined) - : undefined; - _updateProgressWidget( - ctx, - unitType, - unitId, - state, - widgetStateAccessors, - badge, - ); -} - -/** State accessors for the widget — closures over module globals. */ -const widgetStateAccessors: WidgetStateAccessors = { - getAutoStartTime: () => s.autoStartTime, - isStepMode: () => s.stepMode, - getCmdCtx: () => s.cmdCtx, - getBasePath: () => s.basePath, - isVerbose: () => s.verbose, - isSessionSwitching: isSessionSwitchInFlight, - getCurrentDispatchedModelId: () => s.currentDispatchedModelId, -}; - -// ─── Preconditions ──────────────────────────────────────────────────────────── - -/** - * Ensure directories, branches, and other prerequisites exist before - * dispatching a unit. The LLM should never need to mkdir or git checkout. - */ -function ensurePreconditions( - unitType: string, - unitId: string, - base: string, - state: GSDState, -): void { - const { milestone: mid, slice: sid } = parseUnitId(unitId); - - const mDir = resolveMilestonePath(base, mid); - if (!mDir) { - const newDir = join(milestonesDir(base), mid); - mkdirSync(join(newDir, "slices"), { recursive: true }); - } - - if (sid !== undefined) { - - const mDirResolved = resolveMilestonePath(base, mid); - if (mDirResolved) { - const slicesDir = join(mDirResolved, "slices"); - const sDir = resolveDir(slicesDir, sid); - if (!sDir) { - mkdirSync(join(slicesDir, sid, "tasks"), { recursive: true }); - } - const resolvedSliceDir = resolveDir(slicesDir, sid) ?? sid; - const tasksDir = join(slicesDir, resolvedSliceDir, "tasks"); - if (!existsSync(tasksDir)) { - mkdirSync(tasksDir, { recursive: true }); - } - } - } -} - -export async function dispatchHookUnit( - ctx: ExtensionContext, - pi: ExtensionAPI, - hookName: string, - triggerUnitType: string, - triggerUnitId: string, - hookPrompt: string, - hookModel: string | undefined, - targetBasePath: string, -): Promise { - if (!s.active) { - s.active = true; - s.stepMode = true; - s.cmdCtx = ctx as ExtensionCommandContext; - s.basePath = targetBasePath; - s.autoStartTime = Date.now(); - s.currentUnit = null; - s.pendingQuickTasks = []; - } - - const hookUnitType = `hook/${hookName}`; - const hookStartedAt = Date.now(); - - s.currentUnit = { - type: triggerUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return false; - } - - s.currentUnit = { - type: hookUnitType, - id: triggerUnitId, - startedAt: hookStartedAt, - }; - - if (hookModel) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = resolveModelId(hookModel, availableModels, ctx.model?.provider); - if (match) { - try { - await pi.setModel(match); - } catch (err) { - /* non-fatal */ - logWarning("dispatch", `hook model set failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - } else { - ctx.ui.notify( - `Hook model "${hookModel}" not found in available models. Falling back to current session model. ` + - `Ensure the model is defined in models.json and has auth configured.`, - "warning", - ); - } - } - - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock( - lockBase(), - hookUnitType, - triggerUnitId, - sessionFile, - ); - - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - ctx.ui.notify( - `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, - "warning", - ); - resetHookState(); - await pauseAuto(ctx, pi); - }, hookHardTimeoutMs); - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); - - // Ensure cwd matches basePath before hook dispatch (#1389) - try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch (err) { - logWarning("engine", `chdir failed before hook dispatch: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" }); - } - - debugLog("dispatchHookUnit", { - phase: "send-message", - promptLength: hookPrompt.length, - }); - pi.sendMessage( - { customType: "gsd-auto", content: hookPrompt, display: true }, - { triggerTurn: true }, - ); - - return true; -} - -// Re-export recovery functions for external consumers -export { - buildLoopRemediationSteps, -} from "./auto-recovery.js"; -export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js"; diff --git a/src/resources/extensions/gsd/auto/detect-stuck.ts b/src/resources/extensions/gsd/auto/detect-stuck.ts deleted file mode 100644 index 9873e87a6..000000000 --- a/src/resources/extensions/gsd/auto/detect-stuck.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * auto/detect-stuck.ts — Sliding-window stuck detection for the auto-loop. - * - * Leaf node in the import DAG. - */ - -import type { WindowEntry } from "./types.js"; -import { summarizeLogs } from "../workflow-logger.js"; - -/** - * Pattern matching ENOENT errors with a file path. - * Matches: "ENOENT: no such file or directory, access '/path/to/file'" - * and similar Node.js filesystem error messages. - */ -const ENOENT_PATH_RE = /ENOENT[^']*'([^']+)'/; - -/** - * Analyze a sliding window of recent unit dispatches for stuck patterns. - * Returns a signal with reason if stuck, null otherwise. - * - * Rule 1: Same error string twice in a row → stuck immediately. - * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior). - * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck. - * Rule 4: Same ENOENT path in any 2 entries within the window → stuck (#3575). - * Missing files don't self-heal between retries — retrying wastes budget. - */ -export function detectStuck( - window: readonly WindowEntry[], -): { stuck: true; reason: string } | null { - if (window.length < 2) return null; - - // Peek (not drain) the workflow-logger buffer so stuck reasons can surface - // the underlying diagnostic context (projection failures, DB degradations, - // reconcile warnings) that usually explains *why* the loop is stuck. The - // auto-loop's finalize step owns the buffer lifecycle — this is read-only. - const loggerSummary = summarizeLogs(); - const suffix = loggerSummary ? ` — ${loggerSummary}` : ""; - - const last = window[window.length - 1]; - const prev = window[window.length - 2]; - - // Rule 1: Same error repeated consecutively - if (last.error && prev.error && last.error === prev.error) { - return { - stuck: true, - reason: `Same error repeated: ${last.error.slice(0, 200)}${suffix}`, - }; - } - - // Rule 2: Same unit 3+ consecutive times - if (window.length >= 3) { - const lastThree = window.slice(-3); - if (lastThree.every((u) => u.key === last.key)) { - return { - stuck: true, - reason: `${last.key} derived 3 consecutive times without progress${suffix}`, - }; - } - } - - // Rule 3: Oscillation (A→B→A→B in last 4) - if (window.length >= 4) { - const w = window.slice(-4); - if ( - w[0].key === w[2].key && - w[1].key === w[3].key && - w[0].key !== w[1].key - ) { - return { - stuck: true, - reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}${suffix}`, - }; - } - } - - // Rule 4: Same ENOENT path seen twice in window (#3575) - // Missing files don't appear between retries — stop immediately. - const enoentPaths = new Map(); - for (const entry of window) { - if (!entry.error) continue; - const match = ENOENT_PATH_RE.exec(entry.error); - if (!match) continue; - const filePath = match[1]; - const count = (enoentPaths.get(filePath) ?? 0) + 1; - if (count >= 2) { - return { - stuck: true, - reason: `Missing file referenced twice: ${filePath} (ENOENT)${suffix}`, - }; - } - enoentPaths.set(filePath, count); - } - - return null; -} diff --git a/src/resources/extensions/gsd/auto/finalize-timeout.ts b/src/resources/extensions/gsd/auto/finalize-timeout.ts deleted file mode 100644 index f5e073fc9..000000000 --- a/src/resources/extensions/gsd/auto/finalize-timeout.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * auto/finalize-timeout.ts — Timeout guard for post-unit finalization. - * - * Prevents the auto-loop from hanging indefinitely when - * postUnitPostVerification() never resolves (#2344). - * - * Leaf module — no imports from auto/ to avoid circular dependencies. - */ - -/** Timeout for postUnitPreVerification in runFinalize (ms). */ -export const FINALIZE_PRE_TIMEOUT_MS = 60_000; - -/** Timeout for postUnitPostVerification in runFinalize (ms). */ -export const FINALIZE_POST_TIMEOUT_MS = 60_000; - -/** - * Race a promise against a timeout. Returns an object indicating whether - * the timeout fired and the resolved value (if any). - * - * Unlike Promise.race with a rejection, this returns a discriminated - * result so callers can handle timeouts as a recoverable condition - * rather than an exception. - * - * The timeout timer is always cleaned up, whether the promise resolves - * or the timeout fires. - */ -export async function withTimeout( - promise: Promise, - timeoutMs: number, - label: string, -): Promise<{ value: T; timedOut: false } | { value: undefined; timedOut: true }> { - let timeoutHandle: ReturnType | undefined; - - const timeoutPromise = new Promise<{ value: undefined; timedOut: true }>((resolve) => { - timeoutHandle = setTimeout(() => { - resolve({ value: undefined, timedOut: true }); - }, timeoutMs); - }); - - try { - const result = await Promise.race([ - promise.then((value) => ({ value, timedOut: false as const })), - timeoutPromise, - ]); - return result; - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} diff --git a/src/resources/extensions/gsd/auto/infra-errors.ts b/src/resources/extensions/gsd/auto/infra-errors.ts deleted file mode 100644 index d0132724c..000000000 --- a/src/resources/extensions/gsd/auto/infra-errors.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * auto/infra-errors.ts — Infrastructure error detection. - * - * Leaf module with zero transitive dependencies. Used by the auto-loop catch - * block to distinguish unrecoverable OS/filesystem errors from transient - * failures that merit retry. - */ - -/** - * Error codes indicating infrastructure failures that cannot be recovered by - * retrying. Each retry re-dispatches the unit at full LLM cost, so we bail - * immediately rather than burning budget on guaranteed failures. - */ -export const INFRA_ERROR_CODES: ReadonlySet = new Set([ - "ENOSPC", // disk full - "ENOMEM", // out of memory - "EROFS", // read-only file system - "EDQUOT", // disk quota exceeded - "EMFILE", // too many open files (process) - "ENFILE", // too many open files (system) - "EAGAIN", // resource temporarily unavailable (resource exhaustion) - "ECONNREFUSED", // connection refused (offline / local server down) - "ENOTFOUND", // DNS lookup failed (offline / no network) - "ENETUNREACH", // network unreachable (offline / no route) -]); - -/** - * Detect whether an error is an unrecoverable infrastructure failure. - * Checks the `code` property (Node system errors) and falls back to - * scanning the message string for known error code tokens. - * - * Returns the matched code string, or null if the error is not an - * infrastructure failure. - */ -export function isInfrastructureError(err: unknown): string | null { - if (err && typeof err === "object") { - const code = (err as Record).code; - if (typeof code === "string" && INFRA_ERROR_CODES.has(code)) return code; - } - const msg = err instanceof Error ? err.message : String(err); - for (const code of INFRA_ERROR_CODES) { - if (msg.includes(code)) return code; - } - // SQLite WAL corruption is not transient — retrying burns LLM budget - // for guaranteed failures (#2823). - if (msg.includes("database disk image is malformed")) return "SQLITE_CORRUPT"; - return null; -} - -/** - * Default wait duration when a cooldown error is detected but no specific - * expiry is available from AuthStorage (e.g., error propagated across - * process boundary without structured backoff data). - */ -export const COOLDOWN_FALLBACK_WAIT_MS = 35_000; // 35s — slightly longer than the 30s rate-limit backoff - -/** Maximum consecutive cooldown retries before the auto-loop gives up. */ -export const MAX_COOLDOWN_RETRIES = 5; - -/** - * Detect whether an error is a transient credential cooldown that should - * be waited out rather than counted as a consecutive failure. - * - * Prefers the structured `CredentialCooldownError` (code: AUTH_COOLDOWN) - * thrown by sdk.ts. Falls back to message matching for errors that - * propagated across process boundaries without the typed class. - */ -export function isTransientCooldownError(err: unknown): boolean { - if (err && typeof err === "object" && (err as Record).code === "AUTH_COOLDOWN") { - return true; - } - // Fallback: message match for cross-process error propagation - const msg = err instanceof Error ? err.message : String(err); - return /in a cooldown window/i.test(msg); -} - -/** - * Extract retryAfterMs from a CredentialCooldownError, if available. - * Returns undefined for unstructured errors or when no retry hint exists. - */ -export function getCooldownRetryAfterMs(err: unknown): number | undefined { - if (err && typeof err === "object" && (err as Record).code === "AUTH_COOLDOWN") { - return (err as Record).retryAfterMs as number | undefined; - } - return undefined; -} diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts deleted file mode 100644 index 6444c2395..000000000 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * auto/loop-deps.ts — LoopDeps interface for dependency injection into autoLoop. - * - * Leaf node in the import DAG (type-only). - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; - -import type { AutoSession } from "./session.js"; -import type { GSDPreferences } from "../preferences.js"; -import type { GSDState } from "../types.js"; -import type { SessionLockStatus } from "../session-lock.js"; -import type { CloseoutOptions } from "../auto-unit-closeout.js"; -import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js"; -import type { - VerificationContext, - VerificationResult, -} from "../auto-verification.js"; -import type { DispatchAction } from "../auto-dispatch.js"; -import type { WorktreeResolver } from "../worktree-resolver.js"; -import type { CmuxLogLevel } from "../../cmux/index.js"; -import type { JournalEntry } from "../journal.js"; -import type { MergeReconcileResult } from "../auto-recovery.js"; -import type { UokTurnObserver } from "../uok/contracts.js"; - -/** - * Dependencies injected by the caller (auto.ts startAuto) so autoLoop - * can access private functions from auto.ts without exporting them. - */ -export interface LoopDeps { - lockBase: () => string; - buildSnapshotOpts: ( - unitType: string, - unitId: string, - ) => CloseoutOptions & Record; - stopAuto: ( - ctx?: ExtensionContext, - pi?: ExtensionAPI, - reason?: string, - ) => Promise; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - clearUnitTimeout: () => void; - updateProgressWidget: ( - ctx: ExtensionContext, - unitType: string, - unitId: string, - state: GSDState, - ) => void; - syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void; - logCmuxEvent: ( - preferences: GSDPreferences | undefined, - message: string, - level?: CmuxLogLevel, - ) => void; - - // State and cache functions - invalidateAllCaches: () => void; - deriveState: (basePath: string) => Promise; - rebuildState: (basePath: string) => Promise; - loadEffectiveGSDPreferences: () => - | { preferences?: GSDPreferences } - | undefined; - - // Pre-dispatch health gate - preDispatchHealthGate: ( - basePath: string, - ) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>; - - // Worktree sync - syncProjectRootToWorktree: ( - originalBase: string, - basePath: string, - milestoneId: string | null, - ) => void; - - // Resource version guard - checkResourcesStale: (version: string | null) => string | null; - - // Session lock - validateSessionLock: (basePath: string) => SessionLockStatus; - updateSessionLock: ( - basePath: string, - unitType: string, - unitId: string, - sessionFile?: string, - ) => void; - handleLostSessionLock: ( - ctx?: ExtensionContext, - lockStatus?: SessionLockStatus, - ) => void; - - // Milestone transition functions - sendDesktopNotification: ( - title: string, - body: string, - kind: string, - category: string, - projectName?: string, - ) => void; - setActiveMilestoneId: (basePath: string, mid: string) => void; - pruneQueueOrder: (basePath: string, pendingIds: string[]) => void; - isInAutoWorktree: (basePath: string) => boolean; - shouldUseWorktreeIsolation: () => boolean; - mergeMilestoneToMain: ( - basePath: string, - milestoneId: string, - roadmapContent: string, - ) => { pushed: boolean; codeFilesChanged: boolean }; - teardownAutoWorktree: (basePath: string, milestoneId: string) => void; - createAutoWorktree: (basePath: string, milestoneId: string) => string; - captureIntegrationBranch: ( - basePath: string, - mid: string, - ) => void; - getIsolationMode: () => string; - getCurrentBranch: (basePath: string) => string; - autoWorktreeBranch: (milestoneId: string) => string; - resolveMilestoneFile: ( - basePath: string, - milestoneId: string, - fileType: string, - ) => string | null; - reconcileMergeState: (basePath: string, ctx: ExtensionContext) => MergeReconcileResult; - - // Budget/context/secrets - getLedger: () => unknown; - getProjectTotals: (units: unknown) => { cost: number }; - formatCost: (cost: number) => string; - getBudgetAlertLevel: (pct: number) => number; - getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number; - getBudgetEnforcementAction: (enforcement: string, pct: number) => string; - getManifestStatus: ( - basePath: string, - mid: string | undefined, - projectRoot?: string, - ) => Promise<{ pending: unknown[] } | null>; - collectSecretsFromManifest: ( - basePath: string, - mid: string | undefined, - ctx: ExtensionContext, - ) => Promise<{ - applied: unknown[]; - skipped: unknown[]; - existingSkipped: unknown[]; - } | null>; - - // Dispatch - resolveDispatch: (dctx: { - basePath: string; - mid: string; - midTitle: string; - state: GSDState; - prefs: GSDPreferences | undefined; - session?: AutoSession; - }) => Promise; - runPreDispatchHooks: ( - unitType: string, - unitId: string, - prompt: string, - basePath: string, - ) => { - firedHooks: string[]; - action: string; - prompt?: string; - unitType?: string; - model?: string; - }; - getPriorSliceCompletionBlocker: ( - basePath: string, - mainBranch: string, - unitType: string, - unitId: string, - ) => string | null; - getMainBranch: (basePath: string) => string; - // Unit closeout + runtime records - closeoutUnit: ( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, - startedAt: number, - opts?: CloseoutOptions & Record, - ) => Promise; - autoCommitUnit?: ( - basePath: string, - unitType: string, - unitId: string, - ctx?: ExtensionContext, - ) => Promise; - recordOutcome: (unitType: string, tier: string, success: boolean) => void; - writeLock: ( - lockBase: string, - unitType: string, - unitId: string, - sessionFile?: string, - ) => void; - captureAvailableSkills: () => void; - ensurePreconditions: ( - unitType: string, - unitId: string, - basePath: string, - state: GSDState, - ) => void; - updateSliceProgressCache: ( - basePath: string, - mid: string, - sliceId?: string, - ) => void; - - // Model selection + supervision - selectAndApplyModel: ( - ctx: ExtensionContext, - pi: ExtensionAPI, - unitType: string, - unitId: string, - basePath: string, - prefs: GSDPreferences | undefined, - verbose: boolean, - startModel: { provider: string; id: string } | null, - retryContext?: { isRetry: boolean; previousTier?: string }, - isAutoMode?: boolean, - sessionModelOverride?: { provider: string; id: string } | null, - ) => Promise<{ - routing: { tier: string; modelDowngraded: boolean } | null; - appliedModel: { provider: string; id: string } | null; - }>; - resolveModelId: ( - modelId: string, - availableModels: T[], - currentProvider: string | undefined, - ) => T | undefined; - startUnitSupervision: (sctx: { - s: AutoSession; - ctx: ExtensionContext; - pi: ExtensionAPI; - unitType: string; - unitId: string; - prefs: GSDPreferences | undefined; - buildSnapshotOpts: () => CloseoutOptions & Record; - buildRecoveryContext: () => unknown; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - }) => void; - - // Prompt helpers - getDeepDiagnostic: (basePath: string) => string | null; - isDbAvailable: () => boolean; - reorderForCaching: (prompt: string) => string; - - // Filesystem - existsSync: (path: string) => boolean; - readFileSync: (path: string, encoding: string) => string; - atomicWriteSync: (path: string, content: string) => void; - - // Git - GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown; - - // WorktreeResolver - resolver: WorktreeResolver; - - // Post-unit processing - postUnitPreVerification: ( - pctx: PostUnitContext, - opts?: PreVerificationOpts, - ) => Promise<"dispatched" | "continue" | "retry">; - runPostUnitVerification: ( - vctx: VerificationContext, - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, - ) => Promise; - postUnitPostVerification: ( - pctx: PostUnitContext, - ) => Promise<"continue" | "step-wizard" | "stopped">; - - // Session manager - getSessionFile: (ctx: ExtensionContext) => string; - - // Journal - emitJournalEvent: (entry: JournalEntry) => void; - - // UOK (optional, flag-gated) - uokObserver?: UokTurnObserver; -} diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts deleted file mode 100644 index 713b66718..000000000 --- a/src/resources/extensions/gsd/auto/loop.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * auto/loop.ts — Main auto-mode execution loop. - * - * Iterates: derive → dispatch → guards → runUnit → finalize → repeat. - * Exits when s.active becomes false or a terminal condition is reached. - * - * Imports from: auto/types, auto/resolve, auto/phases - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; - -import { randomUUID } from "node:crypto"; -import type { AutoSession, SidecarItem } from "./session.js"; -import type { LoopDeps } from "./loop-deps.js"; -import { - MAX_LOOP_ITERATIONS, - type LoopState, - type IterationContext, - type IterationData, -} from "./types.js"; -import { _clearCurrentResolve } from "./resolve.js"; -import { - runPreDispatch, - runDispatch, - runGuards, - runUnitPhase, - runFinalize, -} from "./phases.js"; -import { debugLog } from "../debug-logger.js"; -import { isInfrastructureError, isTransientCooldownError, getCooldownRetryAfterMs, COOLDOWN_FALLBACK_WAIT_MS, MAX_COOLDOWN_RETRIES } from "./infra-errors.js"; -import { resolveEngine } from "../engine-resolver.js"; -import { logWarning } from "../workflow-logger.js"; -import { gsdRoot } from "../paths.js"; -import { resolveUokFlags } from "../uok/flags.js"; -import { scheduleSidecarQueue } from "../uok/execution-graph.js"; -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; - -// ── Stuck detection persistence (#3704) ────────────────────────────────── -// Persist stuck detection state to disk so it survives session restarts. -// Without this, restarting auto-mode resets all counters, allowing the -// same blocked unit to burn a full retry budget each session. -function stuckStatePath(basePath: string): string { - return join(gsdRoot(basePath), "runtime", "stuck-state.json"); -} - -function loadStuckState(basePath: string): { recentUnits: Array<{ key: string }>; stuckRecoveryAttempts: number } { - try { - const data = JSON.parse(readFileSync(stuckStatePath(basePath), "utf-8")); - return { - recentUnits: Array.isArray(data.recentUnits) ? data.recentUnits : [], - stuckRecoveryAttempts: typeof data.stuckRecoveryAttempts === "number" ? data.stuckRecoveryAttempts : 0, - }; - } catch (err) { - debugLog("autoLoop", { phase: "load-stuck-state-failed", error: err instanceof Error ? err.message : String(err) }); - return { recentUnits: [], stuckRecoveryAttempts: 0 }; - } -} - -function saveStuckState(basePath: string, state: LoopState): void { - try { - const filePath = stuckStatePath(basePath); - mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); - writeFileSync(filePath, JSON.stringify({ - recentUnits: state.recentUnits.slice(-20), // keep last 20 entries - stuckRecoveryAttempts: state.stuckRecoveryAttempts, - updatedAt: new Date().toISOString(), - }) + "\n"); - } catch (err) { - debugLog("autoLoop", { phase: "save-stuck-state-failed", error: err instanceof Error ? err.message : String(err) }); - } -} - -// ── Memory pressure monitoring (#3331) ────────────────────────────────── -// Check heap usage every N iterations and trigger graceful shutdown before -// the OS OOM killer sends SIGKILL. The threshold is 90% of the V8 heap -// limit (--max-old-space-size or default ~1.5-4GB depending on platform). -const MEMORY_CHECK_INTERVAL = 5; // check every 5 iterations -const MEMORY_PRESSURE_THRESHOLD = 0.85; // 85% of heap limit - -function checkMemoryPressure(): { pressured: boolean; heapMB: number; limitMB: number; pct: number } { - const mem = process.memoryUsage(); - // v8.getHeapStatistics() gives heap_size_limit but requires import - // Use a conservative estimate: RSS > 3GB is danger zone on most systems - const heapMB = Math.round(mem.heapUsed / 1024 / 1024); - const rssMB = Math.round(mem.rss / 1024 / 1024); - // Try to get the actual V8 heap limit - let limitMB = 4096; // conservative default - try { - const v8 = require("node:v8"); - const stats = v8.getHeapStatistics(); - limitMB = Math.round(stats.heap_size_limit / 1024 / 1024); - } catch { limitMB = 4096; /* v8 stats unavailable — use conservative default */ } - const pct = heapMB / limitMB; - return { pressured: pct > MEMORY_PRESSURE_THRESHOLD, heapMB, limitMB, pct }; -} - -/** - * Main auto-mode execution loop. Iterates: derive → dispatch → guards → - * runUnit → finalize → repeat. Exits when s.active becomes false or a - * terminal condition is reached. - * - * This is the linear replacement for the recursive - * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain. - */ -export async function autoLoop( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - deps: LoopDeps, -): Promise { - debugLog("autoLoop", { phase: "enter" }); - let iteration = 0; - // Load persisted stuck state so counters survive session restarts (#3704) - const persisted = loadStuckState(s.basePath); - const loopState: LoopState = { - recentUnits: persisted.recentUnits, - stuckRecoveryAttempts: persisted.stuckRecoveryAttempts, - consecutiveFinalizeTimeouts: 0, - }; - let consecutiveErrors = 0; - let consecutiveCooldowns = 0; - const recentErrorMessages: string[] = []; - - while (s.active) { - iteration++; - debugLog("autoLoop", { phase: "loop-top", iteration }); - - // ── Journal: per-iteration flow grouping ── - const flowId = randomUUID(); - let seqCounter = 0; - const nextSeq = () => ++seqCounter; - const turnId = randomUUID(); - s.currentTraceId = flowId; - s.currentTurnId = turnId; - const turnStartedAt = new Date().toISOString(); - let observedUnitType: string | undefined; - let observedUnitId: string | undefined; - let turnFinished = false; - const finishTurn = ( - status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry", - failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" | "git" = "none", - error?: string, - ): void => { - if (turnFinished) return; - turnFinished = true; - deps.uokObserver?.onTurnResult({ - traceId: flowId, - turnId, - iteration, - unitType: observedUnitType, - unitId: observedUnitId, - status, - failureClass, - phaseResults: [], - error, - startedAt: turnStartedAt, - finishedAt: new Date().toISOString(), - }); - s.currentTraceId = null; - s.currentTurnId = null; - }; - deps.uokObserver?.onTurnStart({ - traceId: flowId, - turnId, - iteration, - basePath: s.basePath, - startedAt: turnStartedAt, - }); - - if (iteration > MAX_LOOP_ITERATIONS) { - debugLog("autoLoop", { - phase: "exit", - reason: "max-iterations", - iteration, - }); - await deps.stopAuto( - ctx, - pi, - `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`, - ); - finishTurn("stopped", "manual-attention", "max-iterations"); - break; - } - - // ── Memory pressure check (#3331) ── - // Graceful shutdown before OOM killer sends SIGKILL. - if (iteration % MEMORY_CHECK_INTERVAL === 0) { - const mem = checkMemoryPressure(); - debugLog("autoLoop", { phase: "memory-check", ...mem }); - if (mem.pressured) { - logWarning("dispatch", `Memory pressure: ${mem.heapMB}MB / ${mem.limitMB}MB (${Math.round(mem.pct * 100)}%) — stopping auto-mode to prevent OOM kill`); - await deps.stopAuto( - ctx, - pi, - `Memory pressure: heap at ${mem.heapMB}MB / ${mem.limitMB}MB (${Math.round(mem.pct * 100)}%). ` + - `Stopping gracefully to prevent OOM kill after ${iteration} iterations. ` + - `Resume with /gsd auto to continue from where you left off.`, - ); - finishTurn("stopped", "timeout", "memory-pressure"); - break; - } - } - - if (!s.cmdCtx) { - debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" }); - finishTurn("stopped", "manual-attention", "missing-command-context"); - break; - } - - try { - // ── Blanket try/catch: one bad iteration must not kill the session - const prefs = deps.loadEffectiveGSDPreferences()?.preferences; - const uokFlags = resolveUokFlags(prefs); - - // ── Check sidecar queue before deriveState ── - let sidecarItem: SidecarItem | undefined; - if (s.sidecarQueue.length > 0) { - if (uokFlags.executionGraph && s.sidecarQueue.length > 1) { - try { - s.sidecarQueue = await scheduleSidecarQueue(s.sidecarQueue); - } catch (err) { - logWarning("dispatch", `sidecar queue scheduling failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - sidecarItem = s.sidecarQueue.shift()!; - debugLog("autoLoop", { - phase: "sidecar-dequeue", - kind: sidecarItem.kind, - unitType: sidecarItem.unitType, - unitId: sidecarItem.unitId, - }); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "sidecar-dequeue", data: { kind: sidecarItem.kind, unitType: sidecarItem.unitType, unitId: sidecarItem.unitId } }); - } - - const sessionLockBase = deps.lockBase(); - if (sessionLockBase) { - const lockStatus = deps.validateSessionLock(sessionLockBase); - if (!lockStatus.valid) { - debugLog("autoLoop", { - phase: "session-lock-invalid", - reason: lockStatus.failureReason ?? "unknown", - existingPid: lockStatus.existingPid, - expectedPid: lockStatus.expectedPid, - }); - deps.handleLostSessionLock(ctx, lockStatus); - debugLog("autoLoop", { - phase: "exit", - reason: "session-lock-lost", - detail: lockStatus.failureReason ?? "unknown", - }); - break; - } - } - - const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration, flowId, nextSeq }; - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-start", data: { iteration } }); - let iterData: IterationData; - - // ── Custom engine path ────────────────────────────────────────────── - // When activeEngineId is a non-dev value, bypass runPreDispatch and - // runDispatch entirely — the custom engine drives its own state via - // GRAPH.yaml. Shares runGuards and runUnitPhase with the dev path. - // After unit execution, verifies then reconciles via the engine layer. - // - // SF_ENGINE_BYPASS=1 skips the engine layer entirely — falls through - // to the dev path below. - if (s.activeEngineId != null && s.activeEngineId !== "dev" && !sidecarItem && process.env.SF_ENGINE_BYPASS !== "1") { - debugLog("autoLoop", { phase: "custom-engine-derive", iteration, engineId: s.activeEngineId }); - - const { engine, policy } = resolveEngine({ - activeEngineId: s.activeEngineId, - activeRunDir: s.activeRunDir, - }); - - const engineState = await engine.deriveState(s.basePath); - if (engineState.isComplete) { - await deps.stopAuto(ctx, pi, "Workflow complete"); - break; - } - - debugLog("autoLoop", { phase: "custom-engine-dispatch", iteration }); - const dispatch = await engine.resolveDispatch(engineState, { basePath: s.basePath }); - - if (dispatch.action === "stop") { - await deps.stopAuto(ctx, pi, dispatch.reason ?? "Engine stopped"); - break; - } - if (dispatch.action === "skip") { - continue; - } - - // dispatch.action === "dispatch" - const step = dispatch.step!; - const gsdState = await deps.deriveState(s.basePath); - - iterData = { - unitType: step.unitType, - unitId: step.unitId, - prompt: step.prompt, - finalPrompt: step.prompt, - pauseAfterUatDispatch: false, - state: gsdState, - mid: s.currentMilestoneId ?? "workflow", - midTitle: "Workflow", - isRetry: false, - previousTier: undefined, - }; - observedUnitType = iterData.unitType; - observedUnitId = iterData.unitId; - - // ── Progress widget (mirrors dev path in runDispatch) ── - deps.updateProgressWidget(ctx, iterData.unitType, iterData.unitId, iterData.state); - - // ── Guards (shared with dev path) ── - const guardsResult = await runGuards(ic, s.currentMilestoneId ?? "workflow"); - deps.uokObserver?.onPhaseResult("guard", guardsResult.action, { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - if (guardsResult.action === "break") { - finishTurn("stopped", "manual-attention", "guard-break"); - break; - } - - // ── Unit execution (shared with dev path) ── - const unitPhaseResult = await runUnitPhase(ic, iterData, loopState); - deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - if (unitPhaseResult.action === "break") { - finishTurn("stopped", "execution", "unit-break"); - break; - } - - // ── Verify first, then reconcile (only mark complete on pass) ── - debugLog("autoLoop", { phase: "custom-engine-verify", iteration, unitId: iterData.unitId }); - const verifyResult = await policy.verify(iterData.unitType, iterData.unitId, { basePath: s.basePath }); - if (verifyResult === "pause") { - await deps.pauseAuto(ctx, pi); - deps.uokObserver?.onPhaseResult("custom-engine", "pause", { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - finishTurn("paused", "manual-attention", "custom-engine-verify-pause"); - break; - } - if (verifyResult === "retry") { - debugLog("autoLoop", { phase: "custom-engine-verify-retry", iteration, unitId: iterData.unitId }); - deps.uokObserver?.onPhaseResult("custom-engine", "retry", { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - finishTurn("retry"); - continue; - } - - // Verification passed — mark step complete - debugLog("autoLoop", { phase: "custom-engine-reconcile", iteration, unitId: iterData.unitId }); - const reconcileResult = await engine.reconcile(engineState, { - unitType: iterData.unitType, - unitId: iterData.unitId, - startedAt: s.currentUnit?.startedAt ?? Date.now(), - finishedAt: Date.now(), - }); - - deps.clearUnitTimeout(); - consecutiveErrors = 0; - consecutiveCooldowns = 0; - recentErrorMessages.length = 0; - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } }); - saveStuckState(s.basePath, loopState); // persist across session restarts (#3704) - debugLog("autoLoop", { phase: "iteration-complete", iteration }); - - if (reconcileResult.outcome === "milestone-complete") { - await deps.stopAuto(ctx, pi, "Workflow complete"); - deps.uokObserver?.onPhaseResult("custom-engine", "milestone-complete", { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - finishTurn("completed"); - break; - } - if (reconcileResult.outcome === "pause") { - await deps.pauseAuto(ctx, pi); - deps.uokObserver?.onPhaseResult("custom-engine", "pause", { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - finishTurn("paused", "manual-attention"); - break; - } - if (reconcileResult.outcome === "stop") { - await deps.stopAuto(ctx, pi, reconcileResult.reason ?? "Engine stopped"); - deps.uokObserver?.onPhaseResult("custom-engine", "stop", { - unitType: iterData.unitType, - unitId: iterData.unitId, - reason: reconcileResult.reason, - }); - finishTurn("stopped", "manual-attention", reconcileResult.reason); - break; - } - deps.uokObserver?.onPhaseResult("custom-engine", "continue", { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - finishTurn("completed"); - continue; - } - - if (!sidecarItem) { - // ── Phase 1: Pre-dispatch ───────────────────────────────────────── - const preDispatchResult = await runPreDispatch(ic, loopState); - deps.uokObserver?.onPhaseResult("pre-dispatch", preDispatchResult.action); - if (preDispatchResult.action === "break") { - finishTurn("stopped", "manual-attention", "pre-dispatch-break"); - break; - } - if (preDispatchResult.action === "continue") { - finishTurn("skipped"); - continue; - } - - const preData = preDispatchResult.data; - - // ── Phase 2: Guards ─────────────────────────────────────────────── - const guardsResult = await runGuards(ic, preData.mid); - deps.uokObserver?.onPhaseResult("guard", guardsResult.action); - if (guardsResult.action === "break") { - finishTurn("stopped", "manual-attention", "guard-break"); - break; - } - - // ── Phase 3: Dispatch ───────────────────────────────────────────── - const dispatchResult = await runDispatch(ic, preData, loopState); - deps.uokObserver?.onPhaseResult("dispatch", dispatchResult.action); - if (dispatchResult.action === "break") { - finishTurn("stopped", "manual-attention", "dispatch-break"); - break; - } - if (dispatchResult.action === "continue") { - finishTurn("skipped"); - continue; - } - iterData = dispatchResult.data; - observedUnitType = iterData.unitType; - observedUnitId = iterData.unitId; - } else { - // ── Sidecar path: use values from the sidecar item directly ── - const sidecarState = await deps.deriveState(s.basePath); - iterData = { - unitType: sidecarItem.unitType, - unitId: sidecarItem.unitId, - prompt: sidecarItem.prompt, - finalPrompt: sidecarItem.prompt, - pauseAfterUatDispatch: false, - state: sidecarState, - mid: sidecarState.activeMilestone?.id, - midTitle: sidecarState.activeMilestone?.title, - isRetry: false, previousTier: undefined, - }; - observedUnitType = iterData.unitType; - observedUnitId = iterData.unitId; - deps.uokObserver?.onPhaseResult("dispatch", "sidecar", { - unitType: iterData.unitType, - unitId: iterData.unitId, - sidecarKind: sidecarItem.kind, - }); - } - - const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem); - deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - if (unitPhaseResult.action === "break") { - finishTurn("stopped", "execution", "unit-break"); - break; - } - - // ── Phase 5: Finalize ─────────────────────────────────────────────── - - const finalizeResult = await runFinalize(ic, iterData, loopState, sidecarItem); - deps.uokObserver?.onPhaseResult("finalize", finalizeResult.action, { - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - if (finalizeResult.action === "break") { - const finalizeFailureClass = finalizeResult.reason === "git-closeout-failure" - ? "git" - : "closeout"; - finishTurn("stopped", finalizeFailureClass, "finalize-break"); - break; - } - if (finalizeResult.action === "continue") { - finishTurn("retry"); - continue; - } - - consecutiveErrors = 0; // Iteration completed successfully - consecutiveCooldowns = 0; - recentErrorMessages.length = 0; - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } }); - debugLog("autoLoop", { phase: "iteration-complete", iteration }); - finishTurn("completed"); - } catch (loopErr) { - // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── - const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); - - // Always emit iteration-end on error so the journal records iteration - // completion even on failure (#2344). Without this, errors in - // runFinalize leave the journal incomplete, making diagnosis harder. - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration, error: msg } }); - - // ── Infrastructure errors: immediate stop, no retry ── - // These are unrecoverable (disk full, OOM, etc.). Retrying just burns - // LLM budget on guaranteed failures. - const infraCode = isInfrastructureError(loopErr); - if (infraCode) { - debugLog("autoLoop", { - phase: "infrastructure-error", - iteration, - code: infraCode, - error: msg, - }); - ctx.ui.notify( - `Auto-mode stopped: infrastructure error ${infraCode} — ${msg}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `Infrastructure error (${infraCode}): not recoverable by retry`, - ); - finishTurn("failed", "execution", msg); - break; - } - - // ── Credential cooldown: wait and retry with bounded budget ── - // A 429 triggers a 30s credential backoff in AuthStorage. If the SDK's - // getApiKey() retries couldn't outlast the window, the error surfaces - // here. Wait for the cooldown to clear rather than counting it as a - // consecutive failure — but cap retries so we don't spin for hours - // on persistent quota exhaustion. - if (isTransientCooldownError(loopErr)) { - consecutiveCooldowns++; - const retryAfterMs = getCooldownRetryAfterMs(loopErr); - debugLog("autoLoop", { - phase: "cooldown-wait", - iteration, - consecutiveCooldowns, - retryAfterMs, - error: msg, - }); - - if (consecutiveCooldowns > MAX_COOLDOWN_RETRIES) { - ctx.ui.notify( - `Auto-mode stopped: ${consecutiveCooldowns} consecutive credential cooldowns — rate limit or quota may be persistently exhausted.`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `${consecutiveCooldowns} consecutive credential cooldowns exceeded retry budget`, - ); - break; - } - - const waitMs = (retryAfterMs !== undefined && retryAfterMs > 0 && retryAfterMs <= 60_000) - ? retryAfterMs + 500 // Use structured hint + small buffer - : COOLDOWN_FALLBACK_WAIT_MS; - ctx.ui.notify( - `Credentials in cooldown (${consecutiveCooldowns}/${MAX_COOLDOWN_RETRIES}) — waiting ${Math.round(waitMs / 1000)}s before retrying.`, - "warning", - ); - await new Promise(resolve => setTimeout(resolve, waitMs)); - finishTurn("retry", "timeout", msg); - continue; // Retry iteration without incrementing consecutiveErrors - } - - consecutiveErrors++; - recentErrorMessages.push(msg.length > 120 ? msg.slice(0, 120) + "..." : msg); - debugLog("autoLoop", { - phase: "iteration-error", - iteration, - consecutiveErrors, - error: msg, - }); - - if (consecutiveErrors >= 3) { - // 3+ consecutive: hard stop — something is fundamentally broken - const errorHistory = recentErrorMessages - .map((m, i) => ` ${i + 1}. ${m}`) - .join("\n"); - ctx.ui.notify( - `Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures:\n${errorHistory}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `${consecutiveErrors} consecutive iteration failures`, - ); - finishTurn("failed", "execution", msg); - break; - } else if (consecutiveErrors === 2) { - // 2nd consecutive: try invalidating caches + re-deriving state - ctx.ui.notify( - `Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`, - "warning", - ); - deps.invalidateAllCaches(); - } else { - // 1st error: log and retry — transient failures happen - ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning"); - } - finishTurn("retry", "execution", msg); - } - } - - _clearCurrentResolve(); - debugLog("autoLoop", { phase: "exit", totalIterations: iteration }); -} diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts deleted file mode 100644 index 39fc2fc1c..000000000 --- a/src/resources/extensions/gsd/auto/phases.ts +++ /dev/null @@ -1,2006 +0,0 @@ -/** - * auto/phases.ts — Pipeline phases for the auto-loop. - * - * Contains: runPreDispatch, runDispatch, runGuards, runUnitPhase, runFinalize, - * plus internal helpers generateMilestoneReport and closeoutAndStop. - * - * Imports from: auto/types, auto/detect-stuck, auto/run-unit, auto/loop-deps - */ - -import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@sf-run/pi-coding-agent"; - -import type { AutoSession, SidecarItem } from "./session.js"; -import type { LoopDeps } from "./loop-deps.js"; -import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js"; -import type { Phase } from "../types.js"; -import { - MAX_RECOVERY_CHARS, - BUDGET_THRESHOLDS, - MAX_FINALIZE_TIMEOUTS, - type PhaseResult, - type IterationContext, - type LoopState, - type PreDispatchData, - type IterationData, -} from "./types.js"; -import { detectStuck } from "./detect-stuck.js"; -import { runUnit } from "./run-unit.js"; -import { debugLog } from "../debug-logger.js"; -import { PROJECT_FILES } from "../detection.js"; -import { MergeConflictError } from "../git-service.js"; -import { setCurrentPhase, clearCurrentPhase } from "../../shared/gsd-phase-state.js"; -import { join, basename, dirname, parse as parsePath } from "node:path"; -import { existsSync, cpSync, readdirSync } from "node:fs"; -import { - logWarning, - logError, - _resetLogs, - drainLogs, - drainAndSummarize, - formatForNotification, - hasAnyIssues, -} from "../workflow-logger.js"; -import { gsdRoot } from "../paths.js"; -import { atomicWriteSync } from "../atomic-write.js"; -import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.js"; -import { writeUnitRuntimeRecord } from "../unit-runtime.js"; -import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "./finalize-timeout.js"; -import { getEligibleSlices } from "../slice-parallel-eligibility.js"; -import { startSliceParallel } from "../slice-parallel-orchestrator.js"; -import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js"; -import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "../uok/plan-v2.js"; -import { resolveUokFlags } from "../uok/flags.js"; -import { UokGateRunner } from "../uok/gate-runner.js"; -import { resetEvidence } from "../safety/evidence-collector.js"; -import { resetToolCallCounts, formatToolCallSummary } from "../auto-tool-tracking.js"; -import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js"; -import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; -import { - getWorkflowTransportSupportError, - getRequiredWorkflowToolsForAutoUnit, -} from "../workflow-mcp.js"; -import { resolvePersistModelChanges } from "../preferences.js"; -import { recordLearnedOutcome } from "../learning/runtime.js"; - -// ─── generateMilestoneReport ────────────────────────────────────────────────── - -/** - * Resolve the base path for milestone reports. - * Prefers originalBasePath (project root) over basePath (which may be a worktree). - * Exported for testing as _resolveReportBasePath. - */ -export function _resolveReportBasePath(s: Pick): string { - return s.originalBasePath || s.basePath; -} - -/** - * Resolve the authoritative project base for dispatch guards. - * Prior-milestone completion lives at the project root, even when the active - * unit is running inside an auto worktree. - */ -export function _resolveDispatchGuardBasePath( - s: Pick, -): string { - return s.originalBasePath || s.basePath; -} - -const PLANNING_FLOW_GATE_PHASES: ReadonlySet = new Set([ - "executing", - "summarizing", - "validating-milestone", - "completing-milestone", -]); - -function shouldRunPlanningFlowGate(phase: Phase): boolean { - return PLANNING_FLOW_GATE_PHASES.has(phase); -} - -function shouldSkipArtifactVerification(unitType: string): boolean { - return unitType.startsWith("hook/") || unitType === "custom-step"; -} - -function recordLearningOutcomeForUnit( - ic: IterationContext, - unitType: string, - unitId: string, - startedAt: number | undefined, - outcome: { - succeeded: boolean; - verificationPassed: boolean | null; - blockerDiscovered?: boolean; - retries?: number; - escalated?: boolean; - }, -): void { - if (!startedAt) return; - const unitModel = ic.s.currentUnitModel; - const unitEntry = (ic.deps.getLedger() as { - units?: Array<{ - type: string; - id: string; - startedAt: number; - finishedAt: number; - model: string; - cost: number; - tokens: { total: number }; - }>; - } | null)?.units - ? [...((ic.deps.getLedger() as { - units?: Array<{ - type: string; - id: string; - startedAt: number; - finishedAt: number; - model: string; - cost: number; - tokens: { total: number }; - }>; - } | null)?.units ?? [])].reverse().find( - (u) => u.type === unitType && u.id === unitId && u.startedAt === startedAt, - ) - : undefined; - const provider = unitModel?.provider ?? null; - const modelId = unitModel?.id ?? unitEntry?.model ?? null; - if (!provider || !modelId || !unitEntry) return; - recordLearnedOutcome({ - modelId, - provider, - unitType, - unitId, - succeeded: outcome.succeeded, - retries: outcome.retries ?? 0, - escalated: outcome.escalated ?? false, - verification_passed: outcome.verificationPassed, - blocker_discovered: outcome.blockerDiscovered ?? false, - duration_ms: Math.max(0, unitEntry.finishedAt - unitEntry.startedAt), - tokens_total: unitEntry.tokens.total, - cost_usd: unitEntry.cost, - recorded_at: unitEntry.startedAt, - }); -} - -/** - * Generate and write an HTML milestone report snapshot. - * Extracted from the milestone-transition block in autoLoop. - */ -async function generateMilestoneReport( - s: AutoSession, - ctx: ExtensionContext, - milestoneId: string, -): Promise { - const { loadVisualizerData } = await importExtensionModule(import.meta.url, "../visualizer-data.js"); - const { generateHtmlReport } = await importExtensionModule(import.meta.url, "../export-html.js"); - const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "../reports.js"); - const { basename } = await import("node:path"); - - const reportBasePath = _resolveReportBasePath(s); - - const snapData = await loadVisualizerData(reportBasePath); - const completedMs = snapData.milestones.find( - (m: { id: string }) => m.id === milestoneId, - ); - const msTitle = completedMs?.title ?? milestoneId; - const gsdVersion = process.env.SF_VERSION ?? "0.0.0"; - const projName = basename(reportBasePath); - const doneSlices = snapData.milestones.reduce( - (acc: number, m: { slices: { done: boolean }[] }) => - acc + m.slices.filter((sl: { done: boolean }) => sl.done).length, - 0, - ); - const totalSlices = snapData.milestones.reduce( - (acc: number, m: { slices: unknown[] }) => acc + m.slices.length, - 0, - ); - const outPath = writeReportSnapshot({ - basePath: reportBasePath, - html: generateHtmlReport(snapData, { - projectName: projName, - projectPath: reportBasePath, - gsdVersion, - milestoneId, - indexRelPath: "index.html", - }), - milestoneId, - milestoneTitle: msTitle, - kind: "milestone", - projectName: projName, - projectPath: reportBasePath, - gsdVersion, - totalCost: snapData.totals?.cost ?? 0, - totalTokens: snapData.totals?.tokens.total ?? 0, - totalDuration: snapData.totals?.duration ?? 0, - doneSlices, - totalSlices, - doneMilestones: snapData.milestones.filter( - (m: { status: string }) => m.status === "complete", - ).length, - totalMilestones: snapData.milestones.length, - phase: snapData.phase, - }); - ctx.ui.notify( - `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, - "info", - ); -} - -// ─── closeoutAndStop ────────────────────────────────────────────────────────── - -/** - * If a unit is in-flight, close it out, then stop auto-mode. - * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop. - */ -async function closeoutAndStop( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - deps: LoopDeps, - reason: string, -): Promise { - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - s.currentUnit.type, - s.currentUnit.id, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), - ); - } - await deps.stopAuto(ctx, pi, reason); -} - -async function emitCancelledUnitEnd( - ic: IterationContext, - unitType: string, - unitId: string, - unitStartSeq: number, - errorContext?: { message: string; category: string; stopReason?: string; isTransient?: boolean; retryAfterMs?: number }, -): Promise { - ic.deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "unit-end", - data: { - unitType, - unitId, - status: "cancelled", - artifactVerified: false, - ...(errorContext ? { errorContext } : {}), - }, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); -} - -// ─── runPreDispatch ─────────────────────────────────────────────────────────── - -/** - * Phase 1: Pre-dispatch — resource guard, health gate, state derivation, - * milestone transition, terminal conditions. - * Returns break to exit the loop, or next with PreDispatchData on success. - */ -export async function runPreDispatch( - ic: IterationContext, - loopState: LoopState, -): Promise> { - const { ctx, pi, s, deps, prefs } = ic; - const uokFlags = resolveUokFlags(prefs); - const runPreDispatchGate = async (input: { - gateId: string; - gateType: string; - outcome: "pass" | "fail" | "retry" | "manual-attention"; - failureClass: "none" | "policy" | "input" | "execution" | "artifact" | "verification" | "closeout" | "git" | "timeout" | "manual-attention" | "unknown"; - rationale: string; - findings?: string; - milestoneId?: string; - }): Promise => { - if (!uokFlags.gates) return; - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: input.gateId, - type: input.gateType, - execute: async () => ({ - outcome: input.outcome, - failureClass: input.failureClass, - rationale: input.rationale, - findings: input.findings ?? "", - }), - }); - await gateRunner.run(input.gateId, { - basePath: s.basePath, - traceId: `pre-dispatch:${ic.flowId}`, - turnId: `iter-${ic.iteration}`, - milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined, - unitType: "pre-dispatch", - unitId: `iter-${ic.iteration}`, - }); - }; - - // Resource version guard - const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); - if (staleMsg) { - await runPreDispatchGate({ - gateId: "resource-version-guard", - gateType: "policy", - outcome: "fail", - failureClass: "policy", - rationale: "resource version guard blocked dispatch", - findings: staleMsg, - }); - await deps.stopAuto(ctx, pi, staleMsg); - debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); - return { action: "break", reason: "resources-stale" }; - } - await runPreDispatchGate({ - gateId: "resource-version-guard", - gateType: "policy", - outcome: "pass", - failureClass: "none", - rationale: "resource version guard passed", - }); - - deps.invalidateAllCaches(); - s.lastPromptCharCount = undefined; - s.lastBaselineCharCount = undefined; - - // Pre-dispatch health gate - try { - const healthGate = await deps.preDispatchHealthGate(s.basePath); - if (healthGate.fixesApplied.length > 0) { - ctx.ui.notify( - `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, - "info", - ); - } - if (!healthGate.proceed) { - await runPreDispatchGate({ - gateId: "pre-dispatch-health-gate", - gateType: "execution", - outcome: "manual-attention", - failureClass: "manual-attention", - rationale: "pre-dispatch health gate blocked dispatch", - findings: healthGate.reason, - }); - ctx.ui.notify( - healthGate.reason || "Pre-dispatch health check failed — run /gsd doctor for details.", - "error", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); - return { action: "break", reason: "health-gate-failed" }; - } - await runPreDispatchGate({ - gateId: "pre-dispatch-health-gate", - gateType: "execution", - outcome: "pass", - failureClass: "none", - rationale: "pre-dispatch health gate passed", - findings: healthGate.fixesApplied.length > 0 ? healthGate.fixesApplied.join(", ") : "", - }); - } catch (e) { - await runPreDispatchGate({ - gateId: "pre-dispatch-health-gate", - gateType: "execution", - outcome: "manual-attention", - failureClass: "manual-attention", - rationale: "pre-dispatch health gate threw unexpectedly", - findings: String(e), - }); - logWarning("engine", "Pre-dispatch health gate threw unexpectedly", { error: String(e) }); - } - - // Sync project root artifacts into worktree - if ( - s.originalBasePath && - s.basePath !== s.originalBasePath && - s.currentMilestoneId - ) { - deps.syncProjectRootToWorktree( - s.originalBasePath, - s.basePath, - s.currentMilestoneId, - ); - } - - // Derive state - let state = await deps.deriveState(s.basePath); - const planningFlowEnabled = prefs?.uok?.planning_flow?.enabled === true || prefs?.uok?.plan_v2?.enabled === true; - if (planningFlowEnabled && shouldRunPlanningFlowGate(state.phase)) { - const compiled = ensurePlanningFlowGraph(s.basePath, state); - if (!compiled.ok) { - const reason = compiled.reason ?? "Planning flow compilation failed"; - await runPreDispatchGate({ - gateId: "planning-flow-gate", - gateType: "policy", - outcome: "manual-attention", - failureClass: "manual-attention", - rationale: "planning flow compile gate failed", - findings: reason, - milestoneId: state.activeMilestone?.id ?? undefined, - }); - ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error"); - await deps.pauseAuto(ctx, pi); - return { action: "break", reason: "planning-flow-gate-failed" }; - } - await runPreDispatchGate({ - gateId: "planning-flow-gate", - gateType: "policy", - outcome: "pass", - failureClass: "none", - rationale: "planning flow compile gate passed", - milestoneId: state.activeMilestone?.id ?? undefined, - }); - } - deps.syncCmuxSidebar(prefs, state); - let mid = state.activeMilestone?.id; - let midTitle = state.activeMilestone?.title; - debugLog("autoLoop", { - phase: "state-derived", - iteration: ic.iteration, - mid, - statePhase: state.phase, - }); - - // ── Slice-level parallelism gate (#2340) ───────────────────────────── - // When slice_parallel is enabled, check if multiple slices are eligible - // for parallel execution. If so, dispatch them in parallel and stop the - // sequential loop. Workers are spawned via slice-parallel-orchestrator.ts. - if ( - prefs?.slice_parallel?.enabled && - mid && - !process.env.SF_PARALLEL_WORKER && - isDbAvailable() - ) { - try { - const dbSlices = getMilestoneSlices(mid); - if (dbSlices.length > 0) { - const doneIds = new Set(dbSlices.filter(sl => sl.status === "complete" || sl.status === "done").map(sl => sl.id)); - const sliceInputs = dbSlices.map(sl => ({ - id: sl.id, - done: doneIds.has(sl.id), - depends: sl.depends ?? [], - })); - const eligible = getEligibleSlices(sliceInputs, doneIds); - if (eligible.length > 1) { - debugLog("autoLoop", { - phase: "slice-parallel-dispatch", - iteration: ic.iteration, - mid, - eligibleSlices: eligible.map(e => e.id), - }); - ctx.ui.notify( - `Slice-parallel: dispatching ${eligible.length} eligible slices for ${mid}.`, - "info", - ); - const result = await startSliceParallel( - s.basePath, - mid, - eligible, - { - maxWorkers: prefs.slice_parallel.max_workers ?? 2, - useExecutionGraph: uokFlags.executionGraph, - }, - ); - if (result.started.length > 0) { - ctx.ui.notify( - `Slice-parallel: started ${result.started.length} worker(s): ${result.started.join(", ")}.`, - "info", - ); - await deps.stopAuto(ctx, pi, `Slice-parallel dispatched for ${mid}`); - return { action: "break", reason: "slice-parallel-dispatched" }; - } - // Fall through to sequential if no workers started - } - } - } catch (err) { - debugLog("autoLoop", { - phase: "slice-parallel-check-error", - error: err instanceof Error ? err.message : String(err), - }); - // Non-fatal — fall through to sequential dispatch - } - } - - // ── Milestone transition ──────────────────────────────────────────── - if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "milestone-transition", data: { from: s.currentMilestoneId, to: mid } }); - ctx.ui.notify( - `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, - "info", - ); - deps.sendDesktopNotification( - "SF", - `Milestone ${s.currentMilestoneId} complete!`, - "success", - "milestone", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent( - prefs, - `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, - "success", - ); - - const vizPrefs = prefs; - if (vizPrefs?.auto_visualize) { - ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); - } - if (vizPrefs?.auto_report !== false) { - try { - await generateMilestoneReport(s, ctx, s.currentMilestoneId!); - } catch (err) { - ctx.ui.notify( - `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - } - } - - // Reset dispatch counters for new milestone - s.unitDispatchCount.clear(); - s.unitRecoveryCount.clear(); - s.unitLifetimeDispatches.clear(); - loopState.recentUnits.length = 0; - loopState.stuckRecoveryAttempts = 0; - - // Worktree lifecycle on milestone transition — merge current, enter next - try { - deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui); - } catch (mergeErr) { - if (mergeErr instanceof MergeConflictError) { - // Real code conflicts — stop the loop instead of retrying forever (#2330) - ctx.ui.notify( - `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`); - return { action: "break", reason: "merge-conflict" }; - } - // Non-conflict merge errors — stop auto to avoid advancing with unmerged work - logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId!, error: String(mergeErr) }); - ctx.ui.notify( - `Merge failed: ${mergeErr instanceof Error ? mergeErr.message : String(mergeErr)}. Resolve and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`); - return { action: "break", reason: "merge-failed" }; - } - - // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) - - deps.invalidateAllCaches(); - - state = await deps.deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - - if (mid) { - if (deps.getIsolationMode() !== "none") { - deps.captureIntegrationBranch(s.basePath, mid); - } - deps.resolver.enterMilestone(mid, ctx.ui); - } else { - // mid is undefined — no milestone to capture integration branch for - } - - const pendingIds = state.registry - .filter( - (m: { status: string }) => - m.status !== "complete" && m.status !== "parked", - ) - .map((m: { id: string }) => m.id); - deps.pruneQueueOrder(s.basePath, pendingIds); - - // Archive the old completed-units.json instead of wiping it (#2313). - try { - const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); - if (existsSync(completedKeysPath) && s.currentMilestoneId) { - const archivePath = join( - gsdRoot(s.basePath), - `completed-units-${s.currentMilestoneId}.json`, - ); - cpSync(completedKeysPath, archivePath); - } - atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2)); - } catch (e) { - logWarning("engine", "Failed to archive completed-units on milestone transition", { error: String(e) }); - } - - // Rebuild STATE.md immediately so it reflects the new active milestone. - // This bypasses the 30-second throttle in the normal rebuild path — - // milestone transitions are rare and important enough to warrant an - // immediate write. - try { - await deps.rebuildState(s.basePath); - } catch (e) { - logWarning("engine", "STATE.md rebuild failed after milestone transition", { error: String(e) }); - } - } - - if (mid) { - s.currentMilestoneId = mid; - deps.setActiveMilestoneId(s.basePath, mid); - } - - // ── Terminal conditions ────────────────────────────────────────────── - - if (!mid) { - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - s.currentUnit.type, - s.currentUnit.id, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), - ); - } - - const incomplete = state.registry.filter( - (m: { status: string }) => - m.status !== "complete" && m.status !== "parked", - ); - if (incomplete.length === 0 && state.registry.length > 0) { - // All milestones complete — merge milestone branch before stopping - if (s.currentMilestoneId) { - try { - deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); - // Prevent stopAuto from attempting the same merge (#2645) - s.milestoneMergedInPhases = true; - } catch (mergeErr) { - if (mergeErr instanceof MergeConflictError) { - ctx.ui.notify( - `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`); - return { action: "break", reason: "merge-conflict" }; - } - logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId!, error: String(mergeErr) }); - ctx.ui.notify( - `Merge failed: ${mergeErr instanceof Error ? mergeErr.message : String(mergeErr)}. Resolve and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`); - return { action: "break", reason: "merge-failed" }; - } - - // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) - } - deps.sendDesktopNotification( - "SF", - "All milestones complete!", - "success", - "milestone", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent( - prefs, - "All milestones complete.", - "success", - ); - await deps.stopAuto(ctx, pi, "All milestones complete"); - } else if (incomplete.length === 0 && state.registry.length === 0) { - // Empty registry — no milestones visible, likely a path resolution bug - const diag = `basePath=${s.basePath}, phase=${state.phase}`; - ctx.ui.notify( - `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `No milestones found — check basePath resolution`, - ); - } else if (state.phase === "blocked") { - const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; - // Pause instead of hard-stop so the session is resumable with `/gsd auto`. - // Hard-stop here was causing premature termination when slice dependencies - // were temporarily unresolvable (e.g. after reassessment added new slices). - await deps.pauseAuto(ctx, pi); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto to resume.`, "warning"); - deps.sendDesktopNotification("SF", blockerMsg, "warning", "attention", basename(s.originalBasePath || s.basePath)); - deps.logCmuxEvent(prefs, blockerMsg, "warning"); - } else { - const ids = incomplete.map((m: { id: string }) => m.id).join(", "); - const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; - ctx.ui.notify( - `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, - ); - } - debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "no-active-milestone" } }); - return { action: "break", reason: "no-active-milestone" }; - } - - if (!midTitle) { - midTitle = mid; - ctx.ui.notify( - `Milestone ${mid} has no title in roadmap — using ID as fallback.`, - "warning", - ); - } - - // Mid-merge safety check - const mergeReconcileResult = deps.reconcileMergeState(s.basePath, ctx); - if (mergeReconcileResult === "blocked") { - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "merge-reconciliation-blocked" }); - return { action: "break", reason: "merge-reconciliation-blocked" }; - } - if (mergeReconcileResult === "reconciled") { - deps.invalidateAllCaches(); - state = await deps.deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - } - - if (!mid || !midTitle) { - const noMilestoneReason = !mid - ? "No active milestone after merge reconciliation" - : `Milestone ${mid} has no title after reconciliation`; - await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason); - debugLog("autoLoop", { - phase: "exit", - reason: "no-milestone-after-reconciliation", - }); - return { action: "break", reason: "no-milestone-after-reconciliation" }; - } - - // Terminal: complete - if (state.phase === "complete") { - // Milestone merge on complete (before closeout so branch state is clean) - if (s.currentMilestoneId) { - try { - deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); - // Prevent stopAuto from attempting the same merge (#2645) - s.milestoneMergedInPhases = true; - } catch (mergeErr) { - if (mergeErr instanceof MergeConflictError) { - ctx.ui.notify( - `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`); - return { action: "break", reason: "merge-conflict" }; - } - logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId!, error: String(mergeErr) }); - ctx.ui.notify( - `Merge failed: ${mergeErr instanceof Error ? mergeErr.message : String(mergeErr)}. Resolve and run /gsd auto to resume.`, - "error", - ); - await deps.stopAuto(ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`); - return { action: "break", reason: "merge-failed" }; - } - - // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) - } - deps.sendDesktopNotification( - "SF", - `Milestone ${mid} complete!`, - "success", - "milestone", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent( - prefs, - `Milestone ${mid} complete.`, - "success", - ); - await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); - debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "milestone-complete", milestoneId: mid } }); - return { action: "break", reason: "milestone-complete" }; - } - - // Terminal: blocked — pause instead of hard-stop so the session is resumable. - if (state.phase === "blocked") { - const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - s.currentUnit.type, - s.currentUnit.id, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), - ); - } - await deps.pauseAuto(ctx, pi); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto to resume.`, "warning"); - deps.sendDesktopNotification("SF", blockerMsg, "warning", "attention", basename(s.originalBasePath || s.basePath)); - deps.logCmuxEvent(prefs, blockerMsg, "warning"); - debugLog("autoLoop", { phase: "exit", reason: "blocked" }); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "blocked", blockers: state.blockers } }); - return { action: "break", reason: "blocked" }; - } - - return { action: "next", data: { state, mid, midTitle } }; -} - -// ─── runDispatch ────────────────────────────────────────────────────────────── - -/** - * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks. - * Returns break/continue to control the loop, or next with IterationData on success. - */ -export async function runDispatch( - ic: IterationContext, - preData: PreDispatchData, - loopState: LoopState, -): Promise> { - const { ctx, pi, s, deps, prefs } = ic; - const { state, mid, midTitle } = preData; - const STUCK_WINDOW_SIZE = 6; - - debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration }); - const dispatchResult = await deps.resolveDispatch({ - basePath: s.basePath, - mid, - midTitle, - state, - prefs, - session: s, - }); - - if (dispatchResult.action === "stop") { - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "dispatch-stop", rule: dispatchResult.matchedRule, data: { reason: dispatchResult.reason } }); - // Warning-level stops are recoverable human checkpoints (e.g. UAT verdict - // gate) — pause instead of hard-stopping so the session is resumable with - // `/gsd auto`. Error/info-level stops remain hard stops for infrastructure - // failures and terminal conditions respectively. - // See: https://github.com/singularity-forge/sf-run/issues/2474 - if (dispatchResult.level === "warning") { - ctx.ui.notify(dispatchResult.reason, "warning"); - await deps.pauseAuto(ctx, pi); - } else { - await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); - } - debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); - return { action: "break", reason: "dispatch-stop" }; - } - - if (dispatchResult.action !== "dispatch") { - // Non-dispatch action (e.g. "skip") — re-derive state - await new Promise((r) => setImmediate(r)); - return { action: "continue" }; - } - - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "dispatch-match", rule: dispatchResult.matchedRule, data: { unitType: dispatchResult.unitType, unitId: dispatchResult.unitId } }); - - let unitType = dispatchResult.unitType; - let unitId = dispatchResult.unitId; - let prompt = dispatchResult.prompt; - const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; - - // ── Sliding-window stuck detection with graduated recovery ── - const derivedKey = `${unitType}/${unitId}`; - - if (!s.pendingVerificationRetry) { - loopState.recentUnits.push({ key: derivedKey }); - if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) loopState.recentUnits.shift(); - - const stuckSignal = detectStuck(loopState.recentUnits); - if (stuckSignal) { - debugLog("autoLoop", { - phase: "stuck-check", - unitType, - unitId, - reason: stuckSignal.reason, - recoveryAttempts: loopState.stuckRecoveryAttempts, - }); - - if (loopState.stuckRecoveryAttempts === 0) { - // Level 1: try verifying the artifact, then cache invalidation + retry - loopState.stuckRecoveryAttempts++; - const artifactExists = verifyExpectedArtifact( - unitType, - unitId, - s.basePath, - ); - if (artifactExists) { - debugLog("autoLoop", { - phase: "stuck-recovery", - level: 1, - action: "artifact-found", - }); - ctx.ui.notify( - `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, - "info", - ); - deps.invalidateAllCaches(); - return { action: "continue" }; - } - ctx.ui.notify( - `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, - "warning", - ); - deps.invalidateAllCaches(); - } else { - // Level 2: hard stop — genuinely stuck - debugLog("autoLoop", { - phase: "stuck-detected", - unitType, - unitId, - reason: stuckSignal.reason, - }); - const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath); - const stuckRemediation = buildLoopRemediationSteps(unitType, unitId, s.basePath); - const stuckParts = [`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`]; - if (stuckDiag) stuckParts.push(`Expected: ${stuckDiag}`); - if (stuckRemediation) stuckParts.push(`To recover:\n${stuckRemediation}`); - ctx.ui.notify(stuckParts.join(" "), "error"); - await deps.stopAuto( - ctx, - pi, - `Stuck: ${stuckSignal.reason}`, - ); - return { action: "break", reason: "stuck-detected" }; - } - } else { - // Progress detected — reset recovery counter - if (loopState.stuckRecoveryAttempts > 0) { - debugLog("autoLoop", { - phase: "stuck-counter-reset", - from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "", - to: derivedKey, - }); - loopState.stuckRecoveryAttempts = 0; - } - } - } - - // Pre-dispatch hooks - const preDispatchResult = deps.runPreDispatchHooks( - unitType, - unitId, - prompt, - s.basePath, - ); - if (preDispatchResult.firedHooks.length > 0) { - ctx.ui.notify( - `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, - "info", - ); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "pre-dispatch-hook", data: { firedHooks: preDispatchResult.firedHooks, action: preDispatchResult.action } }); - } - if (preDispatchResult.action === "skip") { - ctx.ui.notify( - `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, - "info", - ); - await new Promise((r) => setImmediate(r)); - return { action: "continue" }; - } - if (preDispatchResult.action === "replace") { - prompt = preDispatchResult.prompt ?? prompt; - if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; - } else if (preDispatchResult.prompt) { - prompt = preDispatchResult.prompt; - } - - const guardBasePath = _resolveDispatchGuardBasePath(s); - const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( - guardBasePath, - deps.getMainBranch(guardBasePath), - unitType, - unitId, - ); - if (priorSliceBlocker) { - await deps.stopAuto(ctx, pi, priorSliceBlocker); - debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); - return { action: "break", reason: "prior-slice-blocker" }; - } - - return { - action: "next", - data: { - unitType, unitId, prompt, finalPrompt: prompt, - pauseAfterUatDispatch, - state, mid, midTitle, - isRetry: false, previousTier: undefined, - hookModelOverride: preDispatchResult.model, - }, - }; -} - -// ─── runGuards ──────────────────────────────────────────────────────────────── - -/** - * Phase 2: Guards — stop directives, budget ceiling, context window, secrets re-check. - * Returns break to exit the loop, or next to proceed to dispatch. - */ -export async function runGuards( - ic: IterationContext, - mid: string, -): Promise { - const { ctx, pi, s, deps, prefs } = ic; - - // ── Stop/Backtrack directive guard (#3487) ── - // Check for unexecuted stop or backtrack captures BEFORE dispatching any unit. - // This ensures user "halt" directives are honored immediately. - // IMPORTANT: Fail-closed — any exception during stop handling still breaks the loop - // to ensure user halt intent is never silently dropped. - try { - const { loadStopCaptures, markCaptureExecuted } = await import("../captures.js"); - const stopCaptures = loadStopCaptures(s.basePath); - if (stopCaptures.length > 0) { - const first = stopCaptures[0]; - const isBacktrack = first.classification === "backtrack"; - const label = isBacktrack - ? `Backtrack directive: ${first.text}` - : `Stop directive: ${first.text}`; - - ctx.ui.notify(label, "warning"); - deps.sendDesktopNotification( - "SF", label, "warning", "stop-directive", - basename(s.originalBasePath || s.basePath), - ); - - // Pause first — ensures auto-mode stops even if later steps fail - await deps.pauseAuto(ctx, pi); - - // For backtrack captures, write the backtrack trigger after pausing - if (isBacktrack) { - try { - const { executeBacktrack } = await import("../triage-resolution.js"); - executeBacktrack(s.basePath, mid, first); - } catch (e) { - debugLog("guards", { phase: "backtrack-execution-error", error: String(e) }); - } - } - - // Mark captures as executed only after successful pause/transition - for (const cap of stopCaptures) { - markCaptureExecuted(s.basePath, cap.id); - } - - debugLog("autoLoop", { phase: "exit", reason: isBacktrack ? "user-backtrack" : "user-stop" }); - return { action: "break", reason: isBacktrack ? "user-backtrack" : "user-stop" }; - } - } catch (e) { - // Fail-closed: if anything in the stop guard throws, break the loop - // rather than silently continuing and dropping user halt intent - debugLog("guards", { phase: "stop-guard-error", error: String(e) }); - return { action: "break", reason: "stop-guard-error" }; - } - - // Budget ceiling guard - const budgetCeiling = prefs?.budget_ceiling; - if (budgetCeiling !== undefined && budgetCeiling > 0) { - const currentLedger = deps.getLedger() as { units: unknown } | null; - // In parallel worker mode, only count cost from the current auto-mode session - // to avoid hitting the ceiling due to historical project-wide spend (#2184). - let costUnits = currentLedger?.units; - if (process.env.SF_PARALLEL_WORKER && s.autoStartTime && Array.isArray(costUnits)) { - const sessionStartISO = new Date(s.autoStartTime).toISOString(); - costUnits = costUnits.filter( - (u: { startedAt?: string }) => u.startedAt != null && u.startedAt >= sessionStartISO, - ); - } - const totalCost = costUnits - ? deps.getProjectTotals(costUnits).cost - : 0; - const budgetPct = totalCost / budgetCeiling; - const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); - const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( - s.lastBudgetAlertLevel, - budgetPct, - ); - const enforcement = prefs?.budget_enforcement ?? "pause"; - const budgetEnforcementAction = deps.getBudgetEnforcementAction( - enforcement, - budgetPct, - ); - - // Data-driven threshold check — loop descending, fire first match - const threshold = BUDGET_THRESHOLDS.find( - (t) => newBudgetAlertLevel >= t.pct, - ); - if (threshold) { - s.lastBudgetAlertLevel = - newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; - - if (threshold.pct === 100 && budgetEnforcementAction !== "none") { - // 100% — special enforcement logic (halt/pause/warn) - const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; - if (budgetEnforcementAction === "halt") { - deps.sendDesktopNotification("SF", msg, "error", "budget", basename(s.originalBasePath || s.basePath)); - await deps.stopAuto(ctx, pi, "Budget ceiling reached"); - debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); - return { action: "break", reason: "budget-halt" }; - } - if (budgetEnforcementAction === "pause") { - ctx.ui.notify( - `${msg} Pausing auto-mode — /gsd auto to override and continue.`, - "warning", - ); - deps.sendDesktopNotification("SF", msg, "warning", "budget", basename(s.originalBasePath || s.basePath)); - deps.logCmuxEvent(prefs, msg, "warning"); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); - return { action: "break", reason: "budget-pause" }; - } - ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); - deps.sendDesktopNotification("SF", msg, "warning", "budget", basename(s.originalBasePath || s.basePath)); - deps.logCmuxEvent(prefs, msg, "warning"); - } else if (threshold.pct < 100) { - // Sub-100% — simple notification - const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`; - ctx.ui.notify(msg, threshold.notifyLevel); - deps.sendDesktopNotification( - "SF", - msg, - threshold.notifyLevel, - "budget", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); - } - } else if (budgetAlertLevel === 0) { - s.lastBudgetAlertLevel = 0; - } - } else { - s.lastBudgetAlertLevel = 0; - } - - // Context window guard - const contextThreshold = prefs?.context_pause_threshold ?? 0; - if (contextThreshold > 0 && s.cmdCtx) { - const contextUsage = s.cmdCtx.getContextUsage(); - if ( - contextUsage && - contextUsage.percent !== null && - contextUsage.percent >= contextThreshold - ) { - const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; - ctx.ui.notify( - `${msg} Run /gsd auto to continue (will start fresh session).`, - "warning", - ); - deps.sendDesktopNotification( - "SF", - `Context ${contextUsage.percent}% — paused`, - "warning", - "attention", - basename(s.originalBasePath || s.basePath), - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "context-window" }); - return { action: "break", reason: "context-window" }; - } - } - - // Secrets re-check gate - try { - const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await deps.collectSecretsFromManifest( - s.basePath, - mid, - ctx, - ); - if ( - result && - result.applied && - result.skipped && - result.existingSkipped - ) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } - } - } catch (err) { - ctx.ui.notify( - `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, - "warning", - ); - } - - return { action: "next", data: undefined as void }; -} - -// ─── runUnitPhase ───────────────────────────────────────────────────────────── - -/** - * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify. - * Returns break or next with unitStartedAt for downstream phases. - */ -export async function runUnitPhase( - ic: IterationContext, - iterData: IterationData, - loopState: LoopState, - sidecarItem?: SidecarItem, -): Promise> { - const { ctx, pi, s, deps, prefs } = ic; - const { unitType, unitId, prompt, state, mid } = iterData; - - debugLog("autoLoop", { - phase: "unit-execution", - iteration: ic.iteration, - unitType, - unitId, - }); - - // ── Worktree health check (#1833, #1843) ──────────────────────────── - // Verify the working directory is a valid git checkout with project - // files before dispatching work. A broken worktree causes agents to - // hallucinate summaries since they cannot read or write any files. - // Uses the shared PROJECT_FILES list from detection.ts to support all - // ecosystems (Rust, Go, Python, Java, etc.), not just JS. - if (s.basePath && unitType === "execute-task") { - const gitMarker = join(s.basePath, ".git"); - const hasGit = deps.existsSync(gitMarker); - if (!hasGit) { - const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`; - debugLog("runUnitPhase", { phase: "worktree-health-fail", basePath: s.basePath, hasGit }); - ctx.ui.notify(msg, "error"); - await deps.stopAuto(ctx, pi, msg); - return { action: "break", reason: "worktree-invalid" }; - } - const hasProjectFile = PROJECT_FILES.some((f) => deps.existsSync(join(s.basePath, f))); - const hasSrcDir = deps.existsSync(join(s.basePath, "src")); - // Xcode bundles have project-specific names (*.xcodeproj, *.xcworkspace) - // that cannot be matched by exact filename — scan the directory by suffix. - let hasXcodeBundle = false; - try { - const entries = deps.existsSync(s.basePath) ? readdirSync(s.basePath) : []; - hasXcodeBundle = entries.some((e: string) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace")); - } catch (err) { - debugLog("runUnitPhase", { phase: "xcode-bundle-scan-failed", basePath: s.basePath, error: String(err) }); - } - // Monorepo support (#2347): if no project files in the worktree directory, - // walk parent directories up to the filesystem root. In monorepos, - // package.json / Cargo.toml etc. live in a parent directory. - let hasProjectFileInParent = false; - if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle) { - let checkDir = dirname(s.basePath); - const { root } = parsePath(checkDir); - while (checkDir !== root) { - // Stop at git repository boundary — ancestors above the repo root - // (e.g. ~ or /usr/local) may contain unrelated project files. - if (deps.existsSync(join(checkDir, ".git"))) break; - if (PROJECT_FILES.some((f) => deps.existsSync(join(checkDir, f)))) { - hasProjectFileInParent = true; - break; - } - checkDir = dirname(checkDir); - } - } - if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle && !hasProjectFileInParent) { - // Greenfield projects won't have project files yet — the first task creates them. - // Log a warning but allow execution to proceed. The .git check above is sufficient - // to ensure we're in a valid working directory. - debugLog("runUnitPhase", { phase: "worktree-health-warn-greenfield", basePath: s.basePath, hasProjectFile, hasSrcDir, hasXcodeBundle }); - ctx.ui.notify(`Warning: ${s.basePath} has no recognized project files — proceeding as greenfield project`, "warning"); - } - } - - // Detect retry and capture previous tier for escalation - const isRetry = !!( - s.currentUnit && - s.currentUnit.type === unitType && - s.currentUnit.id === unitId - ); - const previousTier = s.currentUnitRouting?.tier; - - // Scope workflow-logger buffer to this unit so post-finalize drains are - // per-unit. Without this, the module-level _buffer accumulates across every - // unit in the same Node process (see workflow-logger.ts module header). - _resetLogs(); - s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; - s.lastGitActionFailure = null; - s.lastGitActionStatus = null; - setCurrentPhase(unitType); - s.lastToolInvocationError = null; // #2883: clear stale error from previous unit - resetToolCallCounts(); - const unitStartSeq = ic.nextSeq(); - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: unitStartSeq, eventType: "unit-start", data: { unitType, unitId } }); - ctx.ui.notify(`[unit] ${unitType} ${unitId} starting`, "info"); - deps.captureAvailableSkills(); - writeUnitRuntimeRecord( - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: s.currentUnit.startedAt, - progressCount: 0, - lastProgressKind: "dispatch", - recoveryAttempts: 0, // Reset so re-dispatched units get full recovery budget (#2322) - }, - ); - - // Status bar (widget + preconditions deferred until after model selection — see #2899) - ctx.ui.setStatus("gsd-auto", "auto"); - if (mid) - deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); - - // ── Safety harness: reset evidence + create checkpoint ── - const safetyConfig = resolveSafetyHarnessConfig( - prefs?.safety_harness as Record | undefined, - ); - if (safetyConfig.enabled && safetyConfig.evidence_collection) { - resetEvidence(); - } - // Only checkpoint code-executing units (not lifecycle/planning units) - if (safetyConfig.enabled && safetyConfig.checkpoints && unitType === "execute-task") { - s.checkpointSha = createCheckpoint(s.basePath, unitId); - if (s.checkpointSha) { - debugLog("runUnitPhase", { phase: "checkpoint-created", unitId, sha: s.checkpointSha.slice(0, 8) }); - } - } - - // Prompt injection - let finalPrompt = prompt; - - if (s.pendingVerificationRetry) { - const retryCtx = s.pendingVerificationRetry; - s.pendingVerificationRetry = null; - const capped = - retryCtx.failureContext.length > MAX_RECOVERY_CHARS - ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...failure context truncated]" - : retryCtx.failureContext; - finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; - } - - if (s.pendingCrashRecovery) { - const capped = - s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS - ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...recovery briefing truncated to prevent memory exhaustion]" - : s.pendingCrashRecovery; - finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; - s.pendingCrashRecovery = null; - } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { - const diagnostic = deps.getDeepDiagnostic(s.basePath); - if (diagnostic) { - const cappedDiag = - diagnostic.length > MAX_RECOVERY_CHARS - ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...diagnostic truncated to prevent memory exhaustion]" - : diagnostic; - finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; - } - } - - // Prompt char measurement - s.lastPromptCharCount = finalPrompt.length; - s.lastBaselineCharCount = undefined; - if (deps.isDbAvailable()) { - try { - const { inlineGsdRootFile } = await importExtensionModule(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"), - ]); - s.lastBaselineCharCount = - (decisionsContent?.length ?? 0) + - (requirementsContent?.length ?? 0) + - (projectContent?.length ?? 0); - } catch (e) { - logWarning("engine", "Baseline char count measurement failed", { error: String(e) }); - } - } - - // Cache-optimize prompt section ordering - try { - finalPrompt = deps.reorderForCaching(finalPrompt); - } catch (reorderErr) { - const msg = - reorderErr instanceof Error ? reorderErr.message : String(reorderErr); - logWarning("engine", "Prompt reorder failed", { error: msg }); - } - - // Select and apply model (with tier escalation on retry — normal units only) - const modelResult = await deps.selectAndApplyModel( - ctx, - pi, - unitType, - unitId, - s.basePath, - prefs, - s.verbose, - s.autoModeStartModel, - sidecarItem ? undefined : { isRetry, previousTier }, - undefined, - s.manualSessionModelOverride, - ); - s.currentUnitRouting = - modelResult.routing as AutoSession["currentUnitRouting"]; - s.currentUnitModel = - modelResult.appliedModel as AutoSession["currentUnitModel"]; - - // Apply sidecar/pre-dispatch hook model override (takes priority over standard model selection) - const hookModelOverride = sidecarItem?.model ?? iterData.hookModelOverride; - if (hookModelOverride) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = deps.resolveModelId(hookModelOverride, availableModels, ctx.model?.provider); - if (match) { - const ok = await pi.setModel(match, { persist: resolvePersistModelChanges() }); - if (ok) { - s.currentUnitModel = match as AutoSession["currentUnitModel"]; - ctx.ui.notify(`Hook model override: ${match.provider}/${match.id}`, "info"); - } else { - ctx.ui.notify( - `Hook model "${hookModelOverride}" found but setModel failed. Using default.`, - "warning", - ); - } - } else { - ctx.ui.notify( - `Hook model "${hookModelOverride}" not found in available models. Falling back to current session model. ` + - `Ensure the model is defined in models.json and has auth configured.`, - "warning", - ); - } - } - - // Store the final dispatched model ID so the dashboard can read it (#2899). - // This accounts for hook model overrides applied after selectAndApplyModel. - s.currentDispatchedModelId = s.currentUnitModel - ? `${(s.currentUnitModel as any).provider ?? ""}/${(s.currentUnitModel as any).id ?? ""}` - : null; - - const compatibilityError = getWorkflowTransportSupportError( - s.currentUnitModel?.provider ?? ctx.model?.provider, - getRequiredWorkflowToolsForAutoUnit(unitType), - { - projectRoot: s.basePath, - surface: "auto-mode", - unitType, - authMode: s.currentUnitModel?.provider - ? ctx.modelRegistry.getProviderAuthMode(s.currentUnitModel.provider) - : ctx.model?.provider - ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) - : undefined, - baseUrl: (s.currentUnitModel as any)?.baseUrl ?? ctx.model?.baseUrl, - }, - ); - if (compatibilityError) { - ctx.ui.notify(compatibilityError, "error"); - await deps.stopAuto(ctx, pi, compatibilityError); - return { action: "break", reason: "workflow-capability" }; - } - - // Progress widget + preconditions — deferred to after model selection so the - // widget's first render tick shows the correct model (#2899). - deps.updateProgressWidget(ctx, unitType, unitId, state); - deps.ensurePreconditions(unitType, unitId, s.basePath, state); - - // Start unit supervision - deps.clearUnitTimeout(); - deps.startUnitSupervision({ - s, - ctx, - pi, - unitType, - unitId, - prefs, - buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), - buildRecoveryContext: () => ({ - basePath: s.basePath, - verbose: s.verbose, - currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), - unitRecoveryCount: s.unitRecoveryCount, - }), - pauseAuto: deps.pauseAuto, - }); - - // Write preliminary lock (no session path yet — runUnit creates a new session). - // Crash recovery can still identify the in-flight unit from this lock. - deps.writeLock( - deps.lockBase(), - unitType, - unitId, - ); - - debugLog("autoLoop", { - phase: "runUnit-start", - iteration: ic.iteration, - unitType, - unitId, - }); - const unitResult = await runUnit( - ctx, - pi, - s, - unitType, - unitId, - finalPrompt, - ); - debugLog("autoLoop", { - phase: "runUnit-end", - iteration: ic.iteration, - unitType, - unitId, - status: unitResult.status, - }); - - // Now that runUnit has called newSession(), the session file path is correct. - const sessionFile = deps.getSessionFile(ctx); - deps.updateSessionLock( - deps.lockBase(), - unitType, - unitId, - sessionFile, - ); - deps.writeLock( - deps.lockBase(), - unitType, - unitId, - sessionFile, - ); - - // Tag the most recent window entry with error info for stuck detection - const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1]; - if (lastEntry) { - if (unitResult.errorContext) { - lastEntry.error = `${unitResult.errorContext.category}:${unitResult.errorContext.message}`.slice(0, 200); - } else if (unitResult.status === "error" || unitResult.status === "cancelled") { - lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`; - } else if (unitResult.event?.messages?.length) { - const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1]; - const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg); - if (/error|fail|exception/i.test(msgStr)) { - lastEntry.error = msgStr.slice(0, 200); - } - } - } - - if (unitResult.status === "cancelled") { - // Provider-error pause: pauseAuto already handled cleanup and scheduled - // recovery. Don't hard-stop — just break out of the loop (#2762). - if (unitResult.errorContext?.category === "provider") { - await emitCancelledUnitEnd(ic, unitType, unitId, unitStartSeq, unitResult.errorContext); - debugLog("autoLoop", { phase: "exit", reason: "provider-pause", isTransient: unitResult.errorContext.isTransient }); - return { action: "break", reason: "provider-pause" }; - } - // Session creation timeout (not a structural error): pause auto-mode - // and let the provider-error-resume timer handle recovery (#3767). This - // matches the provider-pause path — break out cleanly, don't hard-stop. - // Structural errors (TypeError, is not a function) are NOT transient - // and must hard-stop to avoid infinite retry loops. - if ( - unitResult.errorContext?.isTransient && - unitResult.errorContext?.category === "timeout" - ) { - ctx.ui.notify( - `Session creation timed out for ${unitType} ${unitId}. Pausing auto-mode (recoverable).`, - "warning", - ); - debugLog("autoLoop", { phase: "session-timeout-pause", unitType, unitId }); - await deps.pauseAuto(ctx, pi); - await deps.autoCommitUnit?.(s.basePath, unitType, unitId, ctx); - await emitCancelledUnitEnd(ic, unitType, unitId, unitStartSeq, unitResult.errorContext); - return { action: "break", reason: "session-timeout" }; - } - // All other cancelled states (structural errors, non-transient failures): hard stop - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(unitType, unitId), - ); - } - await deps.autoCommitUnit?.(s.basePath, unitType, unitId, ctx); - await emitCancelledUnitEnd(ic, unitType, unitId, unitStartSeq, unitResult.errorContext); - ctx.ui.notify( - `Session creation failed for ${unitType} ${unitId}: ${unitResult.errorContext?.message ?? "unknown"}. Stopping auto-mode.`, - "warning", - ); - await deps.stopAuto(ctx, pi, `Session creation failed: ${unitResult.errorContext?.message ?? "unknown"}`); - debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); - return { action: "break", reason: "session-failed" }; - } - - // ── Immediate unit closeout (metrics, activity log, memory) ──────── - // Run right after runUnit() returns so telemetry is never lost to a - // crash between iterations. - // Guard: stopAuto() may have nulled s.currentUnit via s.reset() while - // this coroutine was suspended at `await runUnit(...)` (#2939). - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(unitType, unitId), - ); - } - - // ── Zero tool-call guard (#1833, #2653) ────────────────────────── - // Any unit that completes with 0 tool calls made no real progress — - // likely context exhaustion where all tool calls errored out. Treat - // as failed so the unit is retried in a fresh context instead of - // silently passing through to artifact verification (which loops - // forever when the unit never produced its artifact). - { - const currentLedger = deps.getLedger() as { units: Array<{ type: string; id: string; startedAt: number; toolCalls: number }> } | null; - if (currentLedger?.units) { - const lastUnit = [...currentLedger.units].reverse().find( - (u: { type: string; id: string; startedAt: number; toolCalls: number }) => u.type === unitType && u.id === unitId && u.startedAt === s.currentUnit?.startedAt, - ); - if (lastUnit && lastUnit.toolCalls === 0) { - debugLog("runUnitPhase", { - phase: "zero-tool-calls", - unitType, - unitId, - warning: "Unit completed with 0 tool calls — likely context exhaustion, marking as failed", - }); - ctx.ui.notify( - `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, - "warning", - ); - recordLearningOutcomeForUnit(ic, unitType, unitId, s.currentUnit?.startedAt, { - succeeded: false, - verificationPassed: null, - }); - // Fall through to next iteration where dispatch will re-derive - // and re-dispatch this unit. - return { action: "next", data: { unitStartedAt: s.currentUnit?.startedAt } }; - } - } - } - - if (s.currentUnitRouting) { - deps.recordOutcome( - unitType, - s.currentUnitRouting.tier as "light" | "standard" | "heavy", - true, // success assumed; dispatch will re-dispatch if artifact missing - ); - } - - const skipArtifactVerification = shouldSkipArtifactVerification(unitType); - const artifactVerified = - skipArtifactVerification || - verifyExpectedArtifact(unitType, unitId, s.basePath); - if (artifactVerified) { - s.unitDispatchCount.delete(`${unitType}/${unitId}`); - s.unitRecoveryCount.delete(`${unitType}/${unitId}`); - } - - // Write phase handoff anchor after successful research/planning completion - const anchorPhases = new Set(["research-milestone", "research-slice", "plan-milestone", "plan-slice"]); - if (artifactVerified && mid && anchorPhases.has(unitType)) { - try { - const { writePhaseAnchor } = await import("../phase-anchor.js"); - writePhaseAnchor(s.basePath, mid, { - phase: unitType, - milestoneId: mid, - generatedAt: new Date().toISOString(), - intent: `Completed ${unitType} for ${unitId}`, - decisions: [], - blockers: [], - nextSteps: [], - }); - } catch (err) { /* non-fatal — anchor is advisory */ - logWarning("engine", `phase anchor failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - if (unitResult.status !== "completed" || !artifactVerified) { - recordLearningOutcomeForUnit(ic, unitType, unitId, s.currentUnit?.startedAt, { - succeeded: false, - verificationPassed: null, - }); - } - - deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "unit-end", data: { unitType, unitId, status: unitResult.status, artifactVerified, ...(unitResult.errorContext ? { errorContext: unitResult.errorContext } : {}) }, causedBy: { flowId: ic.flowId, seq: unitStartSeq } }); - - { - const verdict = unitResult.status === "completed" - ? (artifactVerified ? "success" : "blocked") - : unitResult.status === "error" - ? "fail" - : unitResult.status; - const ledger = deps.getLedger() as { - units?: Array<{ type: string; id: string; startedAt: number; cost: number; tokens: { total: number }; toolCalls: number }>; - } | null; - const unitEntry = ledger?.units - ? [...ledger.units].reverse().find( - (u) => u.type === unitType && u.id === unitId && u.startedAt === s.currentUnit?.startedAt, - ) - : undefined; - if (unitEntry) { - const costStr = deps.formatCost(unitEntry.cost); - ctx.ui.notify( - `[unit] ${unitType} ${unitId} ended -> ${verdict} (${costStr}, ${unitEntry.tokens.total} tokens, ${unitEntry.toolCalls} tool calls)`, - "info", - ); - } else { - ctx.ui.notify(`[unit] ${unitType} ${unitId} ended -> ${verdict}`, "info"); - } - const toolSummary = formatToolCallSummary(); - if (toolSummary) { - ctx.ui.notify(`[mcp] ${toolSummary}`, "info"); - } - } - - // ── Safety harness: checkpoint cleanup or rollback ── - if (s.checkpointSha) { - if (unitResult.status === "error" && safetyConfig.auto_rollback) { - const rolled = rollbackToCheckpoint(s.basePath, unitId, s.checkpointSha); - if (rolled) { - ctx.ui.notify(`Rolled back to pre-unit checkpoint for ${unitId}`, "info"); - debugLog("runUnitPhase", { phase: "checkpoint-rollback", unitId }); - } - } else if (unitResult.status === "error") { - ctx.ui.notify( - `Unit ${unitId} failed. Pre-unit checkpoint available at ${s.checkpointSha.slice(0, 8)}`, - "warning", - ); - } else { - // Success — clean up checkpoint ref - cleanupCheckpoint(s.basePath, unitId); - debugLog("runUnitPhase", { phase: "checkpoint-cleaned", unitId }); - } - s.checkpointSha = null; - } - - return { action: "next", data: { unitStartedAt: s.currentUnit?.startedAt } }; -} - -// ─── runFinalize ────────────────────────────────────────────────────────────── - -/** - * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard. - * Returns break/continue/next to control the outer loop. - */ -export async function runFinalize( - ic: IterationContext, - iterData: IterationData, - loopState: LoopState, - sidecarItem?: SidecarItem, -): Promise { - const { ctx, pi, s, deps } = ic; - const { pauseAfterUatDispatch } = iterData; - - debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration }); - - // Clear unit timeout (unit completed) - deps.clearUnitTimeout(); - - // Post-unit context for pre/post verification - const postUnitCtx: PostUnitContext = { - s, - ctx, - pi, - buildSnapshotOpts: deps.buildSnapshotOpts, - lockBase: deps.lockBase, - stopAuto: deps.stopAuto, - pauseAuto: deps.pauseAuto, - updateProgressWidget: deps.updateProgressWidget, - }; - - // Pre-verification processing (commit, doctor, state rebuild, etc.) - // Timeout guard: if postUnitPreVerification hangs (e.g., safety harness - // deadlock, browser teardown hang, worktree sync stall), force-continue - // after timeout so the auto-loop is not permanently frozen (#3757). - // - // On timeout, null out s.currentUnit so the timed-out task's late async - // mutations are harmless — postUnitPreVerification guards all side effects - // behind `if (s.currentUnit)`. The next iteration sets a fresh currentUnit. - // Sidecar items use lightweight pre-verification opts - const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem - ? sidecarItem.kind === "hook" - ? { skipSettleDelay: true, skipWorktreeSync: true } - : { skipSettleDelay: true } - : undefined; - const preUnitSnapshot = s.currentUnit - ? { type: s.currentUnit.type, id: s.currentUnit.id, startedAt: s.currentUnit.startedAt } - : null; - const preResultGuard = await withTimeout( - deps.postUnitPreVerification(postUnitCtx, preVerificationOpts), - FINALIZE_PRE_TIMEOUT_MS, - "postUnitPreVerification", - ); - - if (preResultGuard.timedOut) { - // Detach session from the timed-out unit so late async completions - // cannot mutate state for the next unit (#3757). - s.currentUnit = null; - clearCurrentPhase(); - // Drop any logger entries from the timed-out unit so they don't bleed - // into the next iteration's drain. - drainLogs(); - loopState.consecutiveFinalizeTimeouts++; - debugLog("autoLoop", { - phase: "pre-verification-timeout", - iteration: ic.iteration, - unitType: iterData.unitType, - unitId: iterData.unitId, - consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, - }); - - if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { - ctx.ui.notify( - `postUnitPreVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping auto-mode to prevent budget waste`, - "error", - ); - await deps.stopAuto(ctx, pi, `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`); - return { action: "break", reason: "finalize-timeout-escalation" }; - } - - ctx.ui.notify( - `postUnitPreVerification timed out after ${FINALIZE_PRE_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, - "warning", - ); - return { action: "next", data: undefined as void }; - } - - const preResult = preResultGuard.value; - if (preResult === "dispatched") { - const dispatchedReason = s.lastGitActionFailure - ? "git-closeout-failure" - : "pre-verification-dispatched"; - debugLog("autoLoop", { - phase: "exit", - reason: dispatchedReason, - gitError: s.lastGitActionFailure ?? undefined, - }); - return { action: "break", reason: dispatchedReason }; - } - if (preResult === "retry") { - if (sidecarItem) { - // Sidecar artifact retries are skipped — just continue - debugLog("autoLoop", { phase: "sidecar-artifact-retry-skipped", iteration: ic.iteration }); - } else { - // s.pendingVerificationRetry was set by postUnitPreVerification. - // Continue the loop — next iteration will inject the retry context into the prompt. - debugLog("autoLoop", { phase: "artifact-verification-retry", iteration: ic.iteration }); - return { action: "continue" }; - } - } - - if (pauseAfterUatDispatch) { - ctx.ui.notify( - "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", - "info", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); - return { action: "break", reason: "uat-pause" }; - } - - // Verification gate - // Hook sidecar items skip verification entirely. - // Non-hook sidecar items run verification but skip retries (just continue). - const skipVerification = sidecarItem?.kind === "hook"; - if (!skipVerification) { - const verificationResult = await deps.runPostUnitVerification( - { s, ctx, pi }, - deps.pauseAuto, - ); - - if (verificationResult === "pause") { - recordLearningOutcomeForUnit(ic, iterData.unitType, iterData.unitId, s.currentUnit?.startedAt, { - succeeded: false, - verificationPassed: false, - }); - debugLog("autoLoop", { phase: "exit", reason: "verification-pause" }); - return { action: "break", reason: "verification-pause" }; - } - - if (verificationResult === "retry") { - recordLearningOutcomeForUnit(ic, iterData.unitType, iterData.unitId, s.currentUnit?.startedAt, { - succeeded: false, - verificationPassed: false, - }); - if (sidecarItem) { - // Sidecar verification retries are skipped — just continue - debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration: ic.iteration }); - } else { - // s.pendingVerificationRetry was set by runPostUnitVerification. - // Continue the loop — next iteration will inject the retry context into the prompt. - debugLog("autoLoop", { phase: "verification-retry", iteration: ic.iteration }); - return { action: "continue" }; - } - } - } - - // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) - // Timeout guard: if postUnitPostVerification hangs (e.g., module import - // deadlock, SQLite transaction hang), force-continue after timeout so the - // auto-loop is not permanently frozen (#2344). - const postResultGuard = await withTimeout( - deps.postUnitPostVerification(postUnitCtx), - FINALIZE_POST_TIMEOUT_MS, - "postUnitPostVerification", - ); - - if (postResultGuard.timedOut) { - // Detach session from the timed-out unit so late async completions - // cannot mutate state for the next unit (#3757). - s.currentUnit = null; - clearCurrentPhase(); - // Drop any logger entries from the timed-out unit so they don't bleed - // into the next iteration's drain. - drainLogs(); - loopState.consecutiveFinalizeTimeouts++; - debugLog("autoLoop", { - phase: "post-verification-timeout", - iteration: ic.iteration, - unitType: iterData.unitType, - unitId: iterData.unitId, - consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, - }); - - if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { - ctx.ui.notify( - `postUnitPostVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping auto-mode to prevent budget waste`, - "error", - ); - await deps.stopAuto(ctx, pi, `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`); - return { action: "break", reason: "finalize-timeout-escalation" }; - } - - ctx.ui.notify( - `postUnitPostVerification timed out after ${FINALIZE_POST_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, - "warning", - ); - return { action: "next", data: undefined as void }; - } - - const postResult = postResultGuard.value; - - if (postResult === "stopped") { - debugLog("autoLoop", { - phase: "exit", - reason: "post-verification-stopped", - }); - return { action: "break", reason: "post-verification-stopped" }; - } - - if (postResult === "step-wizard") { - // Step mode — exit the loop (caller handles wizard) - debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); - return { action: "break", reason: "step-wizard" }; - } - - // Both pre and post verification completed without timeout — reset counter - loopState.consecutiveFinalizeTimeouts = 0; - - // Surface accumulated workflow-logger issues for this unit to the user. - // Warnings/errors logged during the unit are buffered in the logger and - // drained here so the user sees a single consolidated post-unit alert. - const finalizedArtifactVerified = - shouldSkipArtifactVerification(iterData.unitType) || - verifyExpectedArtifact(iterData.unitType, iterData.unitId, s.basePath); - if (finalizedArtifactVerified) { - recordLearningOutcomeForUnit(ic, iterData.unitType, iterData.unitId, s.currentUnit?.startedAt, { - succeeded: true, - verificationPassed: iterData.unitType === "execute-task" ? true : null, - }); - } - - if (hasAnyIssues()) { - const { logs } = drainAndSummarize(); - if (logs.length > 0) { - const severity = logs.some((e) => e.severity === "error") ? "error" : "warning"; - ctx.ui.notify(formatForNotification(logs), severity); - } - } - - return { action: "next", data: undefined as void }; -} diff --git a/src/resources/extensions/gsd/auto/resolve.ts b/src/resources/extensions/gsd/auto/resolve.ts deleted file mode 100644 index 6de2eaeee..000000000 --- a/src/resources/extensions/gsd/auto/resolve.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * auto/resolve.ts — Per-unit one-shot promise state and resolution. - * - * Module-level mutable state: `_currentResolve` and `_sessionSwitchInFlight`. - * Setter functions are exported because ES modules can't mutate `let` vars - * across module boundaries. - * - * Imports from: auto/types - */ - -import type { UnitResult, AgentEndEvent, ErrorContext } from "./types.js"; -import type { AutoSession } from "./session.js"; -import { debugLog } from "../debug-logger.js"; - -// ─── Per-unit one-shot promise state ──────────────────────────────────────── -// -// A single module-level resolve function scoped to the current unit execution. -// No queue — if an agent_end arrives with no pending resolver, it is dropped -// (logged as warning). This is simpler and safer than the previous session- -// scoped pendingResolve + pendingAgentEndQueue pattern. - -let _currentResolve: ((result: UnitResult) => void) | null = null; -let _sessionSwitchInFlight = false; - -// ─── Setters (needed for cross-module mutation) ───────────────────────────── - -export function _setCurrentResolve(fn: ((result: UnitResult) => void) | null): void { - _currentResolve = fn; -} - -export function _setSessionSwitchInFlight(v: boolean): void { - _sessionSwitchInFlight = v; -} - -export function _clearCurrentResolve(): void { - _currentResolve = null; -} - -// ─── resolveAgentEnd ───────────────────────────────────────────────────────── - -/** - * Called from the agent_end event handler in index.ts to resolve the - * in-flight unit promise. One-shot: the resolver is nulled before calling - * to prevent double-resolution from model fallback retries. - * - * If no resolver exists (event arrived between loop iterations or during - * session switch), the event is dropped with a debug warning. - */ -export function resolveAgentEnd(event: AgentEndEvent): void { - if (_sessionSwitchInFlight) { - debugLog("resolveAgentEnd", { status: "ignored-during-switch" }); - return; - } - if (_currentResolve) { - debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true }); - const r = _currentResolve; - _currentResolve = null; - r({ status: "completed", event }); - } else { - debugLog("resolveAgentEnd", { - status: "no-pending-resolve", - warning: "agent_end with no pending unit", - }); - } -} - -export function isSessionSwitchInFlight(): boolean { - return _sessionSwitchInFlight; -} - -// ─── resolveAgentEndCancelled ───────────────────────────────────────────────── - -/** - * Force-resolve the pending unit promise with { status: "cancelled" }. - * - * Used by pauseAuto, handleAgentEnd early-return, and supervision catch - * blocks to ensure the autoLoop is never stuck awaiting a promise that - * will never resolve. Safe to call when no resolver is pending (no-op). - */ -export function resolveAgentEndCancelled(errorContext?: ErrorContext): void { - if (_currentResolve) { - debugLog("resolveAgentEndCancelled", { status: "resolving-cancelled" }); - const r = _currentResolve; - _currentResolve = null; - r({ status: "cancelled", ...(errorContext ? { errorContext } : {}) }); - } -} - -// ─── resetPendingResolve (test helper) ─────────────────────────────────────── - -/** - * Reset module-level promise state. Only exported for test cleanup — - * production code should never call this. - */ -export function _resetPendingResolve(): void { - _currentResolve = null; - _sessionSwitchInFlight = false; -} - -/** - * No-op for backward compatibility with tests that previously set the - * active session. The module no longer holds a session reference. - */ -export function _setActiveSession(_session: AutoSession | null): void { - // No-op — kept for test backward compatibility -} diff --git a/src/resources/extensions/gsd/auto/run-unit.ts b/src/resources/extensions/gsd/auto/run-unit.ts deleted file mode 100644 index ce0a1348a..000000000 --- a/src/resources/extensions/gsd/auto/run-unit.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * auto/run-unit.ts — Single unit execution: session create → prompt → await agent_end. - * - * Imports from: auto/types, auto/resolve - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; - -import type { AutoSession } from "./session.js"; -import { NEW_SESSION_TIMEOUT_MS } from "./session.js"; -import type { UnitResult } from "./types.js"; -import { _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js"; -import { debugLog } from "../debug-logger.js"; -import { logWarning, logError } from "../workflow-logger.js"; -import { resolveAutoSupervisorConfig, resolvePersistModelChanges } from "../preferences.js"; - -// Tracks the latest session-switch attempt so a late timeout settlement from an -// older runUnit() call cannot clear the guard for a newer one. -let sessionSwitchGeneration = 0; - -/** - * Execute a single unit: create a new session, send the prompt, and await - * the agent_end promise. Returns a UnitResult describing what happened. - * - * The promise is one-shot: resolveAgentEnd() is the only way to resolve it. - * On session creation failure or timeout, returns { status: 'cancelled' } - * without awaiting the promise. - */ -export async function runUnit( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - unitType: string, - unitId: string, - prompt: string, -): Promise { - debugLog("runUnit", { phase: "start", unitType, unitId }); - - // ── Session creation with timeout ── - debugLog("runUnit", { phase: "session-create", unitType, unitId }); - - let sessionResult: { cancelled: boolean }; - let sessionTimeoutHandle: ReturnType | undefined; - const mySessionSwitchGeneration = ++sessionSwitchGeneration; - _setSessionSwitchInFlight(true); - try { - const sessionPromise = s.cmdCtx!.newSession().finally(() => { - if (sessionSwitchGeneration === mySessionSwitchGeneration) { - _setSessionSwitchInFlight(false); - } - }); - const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => { - sessionTimeoutHandle = setTimeout( - () => resolve({ cancelled: true }), - NEW_SESSION_TIMEOUT_MS, - ); - }); - sessionResult = await Promise.race([sessionPromise, timeoutPromise]); - } catch (sessionErr) { - if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); - const msg = - sessionErr instanceof Error ? sessionErr.message : String(sessionErr); - debugLog("runUnit", { - phase: "session-error", - unitType, - unitId, - error: msg, - }); - return { status: "cancelled", errorContext: { message: `Session creation failed: ${msg}`, category: "session-failed", isTransient: true } }; - } - if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); - - if (sessionResult.cancelled) { - debugLog("runUnit-session-timeout", { unitType, unitId }); - return { status: "cancelled", errorContext: { message: "Session creation timed out", category: "timeout", isTransient: true } }; - } - - if (!s.active) { - return { status: "cancelled" }; - } - - if (s.currentUnitModel && typeof pi.setModel === "function") { - const restored = await pi.setModel(s.currentUnitModel, { persist: resolvePersistModelChanges() }); - if (!restored) { - ctx.ui.notify( - `Failed to restore ${s.currentUnitModel.provider}/${s.currentUnitModel.id} after session creation. Using session default.`, - "warning", - ); - } - } - - // ── Create the agent_end promise (per-unit one-shot) ── - // This happens after newSession completes so session-switch agent_end events - // from the previous session cannot resolve the new unit. - _setSessionSwitchInFlight(false); - const unitPromise = new Promise((resolve) => { - _setCurrentResolve(resolve); - }); - - // Ensure cwd matches basePath before dispatch (#1389). - // async_bash and background jobs can drift cwd away from the worktree. - // Realigning here prevents commits from landing on the wrong branch. - try { - if (process.cwd() !== s.basePath) { - process.chdir(s.basePath); - } - } catch (e) { - logWarning("engine", "Failed to chdir to basePath before dispatch", { basePath: s.basePath, error: String(e) }); - } - - // ── Send the prompt ── - debugLog("runUnit", { phase: "send-message", unitType, unitId }); - - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - - // ── Await agent_end with absolute timeout (H4 fix) ── - // If supervision fails to resolve unitPromise within 30s, treat as cancelled. - // Without this, a crashed agent that never emits agent_end hangs the loop (#3161). - debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId }); - const supervisor = resolveAutoSupervisorConfig(); - const UNIT_HARD_TIMEOUT_MS = Math.max( - 30_000, - ((supervisor.hard_timeout_minutes ?? 30) * 60 * 1000) + 30_000, - ); - let unitTimeoutHandle: ReturnType | undefined; - const timeoutResult = new Promise((resolve) => { - unitTimeoutHandle = setTimeout(() => { - resolve({ status: "cancelled", errorContext: { message: "Unit hard timeout — supervision may have failed", category: "timeout", isTransient: true } }); - }, UNIT_HARD_TIMEOUT_MS); - }); - const result = await Promise.race([unitPromise, timeoutResult]); - if (unitTimeoutHandle) clearTimeout(unitTimeoutHandle); - debugLog("runUnit", { - phase: "agent-end-received", - unitType, - unitId, - status: result.status, - }); - - // Discard trailing follow-up messages (e.g. async_job_result notifications) - // from the completed unit. Without this, queued follow-ups trigger wasteful - // LLM turns before the next session can start (#1642). - // clearQueue() lives on AgentSession but isn't part of the typed - // ExtensionCommandContext interface — call it via runtime check. - try { - const cmdCtxAny = s.cmdCtx as Record | null; - if (typeof cmdCtxAny?.clearQueue === "function") { - (cmdCtxAny.clearQueue as () => unknown)(); - } - } catch (e) { - logWarning("engine", "clearQueue failed after unit completion", { error: String(e) }); - } - - return result; -} diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts deleted file mode 100644 index 7a583f0b6..000000000 --- a/src/resources/extensions/gsd/auto/session.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * AutoSession — encapsulates all mutable auto-mode state into a single instance. - * - * Replaces ~40 module-level variables scattered across auto.ts with typed - * properties on a class instance. Benefits: - * - * - reset() clears everything in one call (was 25+ manual resets in stopAuto) - * - toJSON() provides diagnostic snapshots - * - grep `s.` shows every state access - * - Constructable for testing - * - * MAINTENANCE RULE: All new mutable auto-mode state MUST be added here as a - * class property, not as a module-level variable in auto.ts. If the state - * needs clearing on stop, add it to reset(). Tests in - * auto-session-encapsulation.test.ts enforce that auto.ts has no module-level - * `let` or `var` declarations. - */ - -import type { Api, Model } from "@sf-run/pi-ai"; -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import type { GitServiceImpl } from "../git-service.js"; -import type { CaptureEntry } from "../captures.js"; -import type { BudgetAlertLevel } from "../auto-budget.js"; - -// ─── Exported Types ────────────────────────────────────────────────────────── - -export interface CurrentUnit { - type: string; - id: string; - startedAt: number; -} - -export interface UnitRouting { - tier: string; - modelDowngraded: boolean; -} - -export interface StartModel { - provider: string; - id: string; -} - -export interface PendingVerificationRetry { - unitId: string; - failureContext: string; - attempt: number; -} - -/** - * A typed item enqueued by postUnitPostVerification for the main loop to - * drain via the standard runUnit path. Replaces inline dispatch - * (pi.sendMessage / s.cmdCtx.newSession()) for hooks, triage, and quick-tasks. - */ -export interface SidecarItem { - kind: "hook" | "triage" | "quick-task"; - unitType: string; - unitId: string; - prompt: string; - /** Model override for hook units (e.g. "anthropic/claude-3-5-sonnet"). */ - model?: string; - /** Capture ID for quick-task items (already marked executed at enqueue time). */ - captureId?: string; -} - -// ─── Constants ─────────────────────────────────────────────────────────────── - -export const MAX_UNIT_DISPATCHES = 3; -export const STUB_RECOVERY_THRESHOLD = 2; -export const MAX_LIFETIME_DISPATCHES = 6; -export const NEW_SESSION_TIMEOUT_MS = 120_000; - -// ─── AutoSession ───────────────────────────────────────────────────────────── - -export class AutoSession { - // ── Lifecycle ──────────────────────────────────────────────────────────── - active = false; - paused = false; - stepMode = false; - verbose = false; - activeEngineId: string | null = null; - activeRunDir: string | null = null; - cmdCtx: ExtensionCommandContext | null = null; - - // ── Paths ──────────────────────────────────────────────────────────────── - basePath = ""; - originalBasePath = ""; - previousProjectRootEnv: string | null = null; - hadProjectRootEnv = false; - projectRootEnvCaptured = false; - previousMilestoneLockEnv: string | null = null; - hadMilestoneLockEnv = false; - milestoneLockEnvCaptured = false; - sessionMilestoneLock: string | null = null; - gitService: GitServiceImpl | null = null; - - // ── Dispatch counters ──────────────────────────────────────────────────── - readonly unitDispatchCount = new Map(); - readonly unitLifetimeDispatches = new Map(); - readonly unitRecoveryCount = new Map(); - - // ── Timers ─────────────────────────────────────────────────────────────── - unitTimeoutHandle: ReturnType | null = null; - wrapupWarningHandle: ReturnType | null = null; - idleWatchdogHandle: ReturnType | null = null; - continueHereHandle: ReturnType | null = null; - - // ── Current unit ───────────────────────────────────────────────────────── - currentUnit: CurrentUnit | null = null; - currentTraceId: string | null = null; - currentTurnId: string | null = null; - currentUnitRouting: UnitRouting | null = null; - currentMilestoneId: string | null = null; - - // ── Model state ────────────────────────────────────────────────────────── - autoModeStartModel: StartModel | null = null; - /** Explicit /gsd model pin captured at bootstrap (session-scoped policy override). */ - manualSessionModelOverride: StartModel | null = null; - currentUnitModel: Model | null = null; - /** Fully-qualified model ID (provider/id) set after selectAndApplyModel + hook overrides (#2899). */ - currentDispatchedModelId: string | null = null; - originalModelId: string | null = null; - originalModelProvider: string | null = null; - lastBudgetAlertLevel: BudgetAlertLevel = 0; - - // ── Recovery ───────────────────────────────────────────────────────────── - pendingCrashRecovery: string | null = null; - pendingVerificationRetry: PendingVerificationRetry | null = null; - readonly verificationRetryCount = new Map(); - pausedSessionFile: string | null = null; - pausedUnitType: string | null = null; - pausedUnitId: string | null = null; - resourceVersionOnStart: string | null = null; - lastStateRebuildAt = 0; - - // ── Sidecar queue ───────────────────────────────────────────────────── - sidecarQueue: SidecarItem[] = []; - - // ── Tool invocation errors (#2883) ────────────────────────────────── - /** Set when a SF tool execution ends with isError due to malformed/truncated - * JSON arguments. Checked by postUnitPreVerification to break retry loops. */ - lastToolInvocationError: string | null = null; - /** Set when turn-level git action fails during closeout. */ - lastGitActionFailure: string | null = null; - /** Last turn-level git action status captured during finalize. */ - lastGitActionStatus: "ok" | "failed" | null = null; - - // ── Isolation degradation ──────────────────────────────────────────── - /** Set to true when worktree creation fails; prevents merge of nonexistent branch. */ - isolationDegraded = false; - - // ── Merge guard ────────────────────────────────────────────────────── - /** Set to true after phases.ts successfully calls mergeAndExit, so that - * stopAuto does not attempt the same merge a second time (#2645). */ - milestoneMergedInPhases = false; - - // ── Dispatch circuit breakers ────────────────────────────────────── - rewriteAttemptCount = 0; - /** Tracks consecutive bootstrap attempts that found phase === "complete". - * Moved from module-level to per-session so s.reset() clears it (#1348). */ - consecutiveCompleteBootstraps = 0; - - // ── Metrics ────────────────────────────────────────────────────────────── - autoStartTime = 0; - lastPromptCharCount: number | undefined; - lastBaselineCharCount: number | undefined; - pendingQuickTasks: CaptureEntry[] = []; - - // ── Safety harness ─────────────────────────────────────────────────────── - /** SHA of the pre-unit git checkpoint ref. Cleared on success or rollback. */ - checkpointSha: string | null = null; - - // ── Signal handler ─────────────────────────────────────────────────────── - sigtermHandler: (() => void) | null = null; - - // ── Loop promise state ────────────────────────────────────────────────── - // Per-unit resolve function and session-switch guard live at module level - // in auto-loop.ts (_currentResolve, _sessionSwitchInFlight). - - // ── Methods ────────────────────────────────────────────────────────────── - - clearTimers(): void { - if (this.unitTimeoutHandle) { clearTimeout(this.unitTimeoutHandle); this.unitTimeoutHandle = null; } - if (this.wrapupWarningHandle) { clearTimeout(this.wrapupWarningHandle); this.wrapupWarningHandle = null; } - if (this.idleWatchdogHandle) { clearInterval(this.idleWatchdogHandle); this.idleWatchdogHandle = null; } - if (this.continueHereHandle) { clearInterval(this.continueHereHandle); this.continueHereHandle = null; } - } - - resetDispatchCounters(): void { - this.unitDispatchCount.clear(); - this.unitLifetimeDispatches.clear(); - } - - get lockBasePath(): string { - return this.originalBasePath || this.basePath; - } - - reset(): void { - this.clearTimers(); - - // Lifecycle - this.active = false; - this.paused = false; - this.stepMode = false; - this.verbose = false; - this.activeEngineId = null; - this.activeRunDir = null; - this.cmdCtx = null; - - // Paths - this.basePath = ""; - this.originalBasePath = ""; - this.previousProjectRootEnv = null; - this.hadProjectRootEnv = false; - this.projectRootEnvCaptured = false; - this.previousMilestoneLockEnv = null; - this.hadMilestoneLockEnv = false; - this.milestoneLockEnvCaptured = false; - this.sessionMilestoneLock = null; - this.gitService = null; - - // Dispatch - this.unitDispatchCount.clear(); - this.unitLifetimeDispatches.clear(); - this.unitRecoveryCount.clear(); - - // Unit - this.currentUnit = null; - this.currentTraceId = null; - this.currentTurnId = null; - this.currentUnitRouting = null; - this.currentMilestoneId = null; - - // Model - this.autoModeStartModel = null; - this.manualSessionModelOverride = null; - this.currentUnitModel = null; - this.currentDispatchedModelId = null; - this.originalModelId = null; - this.originalModelProvider = null; - this.lastBudgetAlertLevel = 0; - - // Recovery - this.pendingCrashRecovery = null; - this.pendingVerificationRetry = null; - this.verificationRetryCount.clear(); - this.pausedSessionFile = null; - this.pausedUnitType = null; - this.pausedUnitId = null; - this.resourceVersionOnStart = null; - this.lastStateRebuildAt = 0; - - // Metrics - this.autoStartTime = 0; - this.lastPromptCharCount = undefined; - this.lastBaselineCharCount = undefined; - this.pendingQuickTasks = []; - this.sidecarQueue = []; - this.rewriteAttemptCount = 0; - this.consecutiveCompleteBootstraps = 0; - this.lastToolInvocationError = null; - this.lastGitActionFailure = null; - this.lastGitActionStatus = null; - this.isolationDegraded = false; - this.milestoneMergedInPhases = false; - this.checkpointSha = null; - - // Signal handler - this.sigtermHandler = null; - - // Loop promise state lives in auto-loop.ts module scope - } - - toJSON(): Record { - return { - active: this.active, - paused: this.paused, - stepMode: this.stepMode, - basePath: this.basePath, - activeEngineId: this.activeEngineId, - activeRunDir: this.activeRunDir, - currentMilestoneId: this.currentMilestoneId, - currentUnit: this.currentUnit, - unitDispatchCount: Object.fromEntries(this.unitDispatchCount), - }; - } -} diff --git a/src/resources/extensions/gsd/auto/types.ts b/src/resources/extensions/gsd/auto/types.ts deleted file mode 100644 index a2ca21d2b..000000000 --- a/src/resources/extensions/gsd/auto/types.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * auto/types.ts — Constants and types shared across auto-loop modules. - * - * Leaf node in the import DAG — no imports from auto/. - */ - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; - -import type { AutoSession } from "./session.js"; -import type { GSDPreferences } from "../preferences.js"; -import type { GSDState } from "../types.js"; -import type { CmuxLogLevel } from "../../cmux/index.js"; -import type { LoopDeps } from "./loop-deps.js"; - -/** - * Maximum total loop iterations before forced stop. Prevents runaway loops - * when units alternate IDs (bypassing the same-unit stuck detector). - * A milestone with 20 slices × 5 tasks × 3 phases ≈ 300 units. 500 gives - * generous headroom including retries and sidecar work. - */ -export const MAX_LOOP_ITERATIONS = 500; -/** Maximum characters of failure/crash context included in recovery prompts. */ -export const MAX_RECOVERY_CHARS = 50_000; - -/** Data-driven budget threshold notifications (descending). The 100% entry - * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire - * a simple notification. */ -export const BUDGET_THRESHOLDS: Array<{ - pct: number; - label: string; - notifyLevel: "info" | "warning" | "error"; - cmuxLevel: "progress" | "warning" | "error"; -}> = [ - { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" }, - { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" }, - { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" }, - { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" }, -]; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** - * Minimal shape of the event parameter from pi.on("agent_end", ...). - * The full event has more fields, but the loop only needs messages. - */ -export interface AgentEndEvent { - messages: unknown[]; -} - -/** - * Structured error context attached to a UnitResult when the unit ends - * due to an infrastructure or timeout error (not user-driven cancellation). - */ -export interface ErrorContext { - message: string; - category: "provider" | "timeout" | "idle" | "network" | "aborted" | "session-failed" | "unknown"; - stopReason?: string; - isTransient?: boolean; - retryAfterMs?: number; -} - -/** - * Result of a single unit execution (one iteration of the loop). - */ -export interface UnitResult { - status: "completed" | "cancelled" | "error"; - event?: AgentEndEvent; - errorContext?: ErrorContext; -} - -// ─── Phase pipeline types ──────────────────────────────────────────────────── - -export type PhaseResult = - | { action: "continue" } - | { action: "break"; reason: string } - | { action: "next"; data: T } - -export interface IterationContext { - ctx: ExtensionContext; - pi: ExtensionAPI; - s: AutoSession; - deps: LoopDeps; - prefs: GSDPreferences | undefined; - iteration: number; - /** UUID grouping all journal events for this iteration. */ - flowId: string; - /** Returns the next monotonically increasing sequence number (1-based, reset per iteration). */ - nextSeq: () => number; -} - -export interface LoopState { - recentUnits: Array<{ key: string; error?: string }>; - stuckRecoveryAttempts: number; - /** Consecutive finalize timeout count — stops auto-mode after threshold. */ - consecutiveFinalizeTimeouts: number; -} - -/** Max consecutive finalize timeouts before hard-stopping auto-mode. */ -export const MAX_FINALIZE_TIMEOUTS = 3; - -export interface PreDispatchData { - state: GSDState; - mid: string; - midTitle: string; -} - -export interface IterationData { - unitType: string; - unitId: string; - prompt: string; - finalPrompt: string; - pauseAfterUatDispatch: boolean; - state: GSDState; - mid: string | undefined; - midTitle: string | undefined; - isRetry: boolean; - previousTier: string | undefined; - /** Model override from pre-dispatch hooks (applied after standard model selection). */ - hookModelOverride?: string; -} - -export type WindowEntry = { key: string; error?: string }; diff --git a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts b/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts deleted file mode 100644 index 6478e17d9..000000000 --- a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; - -import { logWarning } from "../workflow-logger.js"; -import { checkAutoStartAfterDiscuss } from "../guided-flow.js"; -import { getAutoDashboardData, getAutoModeStartModel, isAutoActive, pauseAuto } from "../auto.js"; -import { getNextFallbackModel, resolveModelWithFallbacksForUnit, resolvePersistModelChanges } from "../preferences.js"; -import { pauseAutoForProviderError } from "../provider-error-pause.js"; -import { isSessionSwitchInFlight, resolveAgentEnd } from "../auto-loop.js"; -import { resolveModelId } from "../auto-model-selection.js"; -import { clearDiscussionFlowState } from "./write-gate.js"; -import { resumeAutoAfterProviderDelay } from "./provider-error-resume.js"; -import { - classifyError, - createRetryState, - resetRetryState, - isTransient, - type ErrorClass, -} from "../error-classifier.js"; - -const retryState = createRetryState(); -const MAX_NETWORK_RETRIES = 2; -const MAX_TRANSIENT_AUTO_RESUMES = 8; - -/** - * Reset the module-level retry state so a resumed auto-session starts fresh. - * Called by provider-error-resume.ts before startAuto() — without this, the - * consecutiveTransientCount accumulates across pause/resume cycles and locks - * out auto-resume after MAX_TRANSIENT_AUTO_RESUMES total (not consecutive) errors. - */ -export function resetTransientRetryState(): void { - resetRetryState(retryState); -} - -async function pauseTransientWithBackoff( - cls: ErrorClass, - pi: ExtensionAPI, - ctx: ExtensionContext, - errorDetail: string, - isRateLimit: boolean, -): Promise { - retryState.consecutiveTransientCount += 1; - const baseRetryAfterMs = "retryAfterMs" in cls ? cls.retryAfterMs : 15_000; - const retryAfterMs = baseRetryAfterMs * 2 ** Math.max(0, retryState.consecutiveTransientCount - 1); - const allowAutoResume = retryState.consecutiveTransientCount <= MAX_TRANSIENT_AUTO_RESUMES; - if (!allowAutoResume) { - ctx.ui.notify(`Transient provider errors persisted after ${MAX_TRANSIENT_AUTO_RESUMES} auto-resume attempts. Pausing for manual review.`, "warning"); - } - await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi, { - message: `Provider error: ${errorDetail}`, - category: "provider", - isTransient: allowAutoResume, - retryAfterMs, - }), { - isRateLimit, - isTransient: allowAutoResume, - retryAfterMs, - resume: allowAutoResume - ? () => { - void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => { - const message = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Provider error recovery delay elapsed, but auto-mode failed to resume: ${message}`, "error"); - }); - } - : undefined, - }); -} - -export async function handleAgentEnd( - pi: ExtensionAPI, - event: { messages: any[] }, - ctx: ExtensionContext, -): Promise { - const persistModelChanges = resolvePersistModelChanges(); - if (checkAutoStartAfterDiscuss()) { - clearDiscussionFlowState(); - return; - } - if (!isAutoActive()) return; - if (isSessionSwitchInFlight()) return; - - const lastMsg = event.messages[event.messages.length - 1]; - if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") { - // Empty content with aborted stopReason is a non-fatal agent stop (the LLM - // chose to end without producing output). Only pause on genuine fatal aborts - // that carry error context — e.g. errorMessage field or non-empty content - // indicating a mid-stream failure. (#2695) - const content = "content" in lastMsg ? lastMsg.content : undefined; - const hasEmptyContent = Array.isArray(content) && content.length === 0; - const hasErrorMessage = "errorMessage" in lastMsg && !!lastMsg.errorMessage; - - if (hasEmptyContent && !hasErrorMessage) { - // Non-fatal: treat as a normal agent end so the loop can continue - // instead of entering a stuck re-dispatch cycle. - try { - resetRetryState(retryState); - resolveAgentEnd(event); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Auto-mode error after empty-content abort: ${message}. Stopping auto-mode.`, "error"); - try { await pauseAuto(ctx, pi); } catch (e) { logWarning("bootstrap", `pauseAuto failed after empty-content abort: ${(e as Error).message}`); } - } - return; - } - - await pauseAuto(ctx, pi); - return; - } - if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "error") { - // #3588: errorMessage can be useless (e.g. "success") while the real error - // is in the assistant message text content. Fall back to content when - // errorMessage looks uninformative. - const rawErrorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : ""; - const isUseless = !rawErrorMsg || /^(success|ok|true|error|unknown)$/i.test(rawErrorMsg.trim()); - // #3588: When errorMessage is uninformative, extract the real error from - // the assistant message text content for display purposes only. - // Classification still uses rawErrorMsg to avoid false positives from prose. - let displayMsg = rawErrorMsg; - if (isUseless && "content" in lastMsg && Array.isArray(lastMsg.content)) { - const textBlock = lastMsg.content.find((b: any) => b.type === "text" && b.text); - if (textBlock) displayMsg = (textBlock as any).text.slice(0, 300); - } - const errorDetail = displayMsg ? `: ${displayMsg}` : ""; - const explicitRetryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number") ? lastMsg.retryAfterMs : undefined; - - // ── 1. Classify using rawErrorMsg to avoid prose false-positives ──── - const cls = classifyError(rawErrorMsg, explicitRetryAfterMs); - - // ── 1b. Defer to Core RetryHandler for transient errors ───────────── - // The Core RetryHandler (agent-session.ts) processes retryable errors - // AFTER this extension handler, in the same _processAgentEvent() call. - // For transient errors (overloaded, rate limit, server), the Core will - // retry in-context — same session, same conversation — which is strictly - // better than our Layer 2 pause+resume (which creates a new session). - // - // If we react here AND the Core also retries, we race: pauseAuto tears - // down the session while agent.continue() starts a new turn. - // - // Solution: Do nothing for transient errors. The Core RetryHandler - // runs next in _processAgentEvent and will either: - // a) Retry successfully → new agent_end (success) → we see it next time - // b) Exhaust retries → the agent stays idle, autoLoop's unit timeout - // or stuck detection handles it - // - // We do NOT call resolveAgentEnd here — that would unblock autoLoop - // prematurely while the Core is still retrying in the same session. - // We do NOT call pauseAuto — that would tear down the session. - if (isTransient(cls)) { - return; - } - - // Cap rate-limit backoff for CLI-style providers (openai-codex, google-gemini-cli) - // which use per-user quotas with shorter windows (#2922). - if (cls.kind === "rate-limit") { - const currentProvider = ctx.model?.provider; - if (currentProvider === "openai-codex" || currentProvider === "google-gemini-cli") { - cls.retryAfterMs = Math.min(cls.retryAfterMs, 30_000); - } - } - - // ── 2. Decide & Act ────────────────────────────────────────────────── - - // --- Network errors: same-model retry with backoff --- - if (cls.kind === "network") { - const currentModelId = ctx.model?.id ?? "unknown"; - if (retryState.currentRetryModelId !== currentModelId) { - retryState.networkRetryCount = 0; - retryState.currentRetryModelId = currentModelId; - } - if (retryState.networkRetryCount < MAX_NETWORK_RETRIES) { - retryState.networkRetryCount += 1; - retryState.consecutiveTransientCount += 1; - const attempt = retryState.networkRetryCount; - const delayMs = attempt * cls.retryAfterMs; - ctx.ui.notify(`Network error on ${currentModelId}${errorDetail}. Retry ${attempt}/${MAX_NETWORK_RETRIES} in ${delayMs / 1000}s...`, "warning"); - setTimeout(() => { - pi.sendMessage( - { customType: "gsd-auto-timeout-recovery", content: "Continue execution — retrying after transient network error.", display: false }, - { triggerTurn: true }, - ); - }, delayMs); - return; - } - // Network retries exhausted — fall through to model fallback - retryState.networkRetryCount = 0; - retryState.currentRetryModelId = undefined; - ctx.ui.notify(`Network retries exhausted for ${currentModelId}. Attempting model fallback.`, "warning"); - } - - // --- Transient errors: try model fallback first, then pause --- - // Rate limits are often per-model, so switching models can bypass them. - if (cls.kind === "rate-limit" || cls.kind === "network" || cls.kind === "server" || cls.kind === "connection" || cls.kind === "stream") { - // Try model fallback - const dash = getAutoDashboardData(); - if (dash.currentUnit) { - const modelConfig = resolveModelWithFallbacksForUnit(dash.currentUnit.type); - if (modelConfig && modelConfig.fallbacks.length > 0) { - const availableModels = ctx.modelRegistry.getAvailable(); - const nextModelId = getNextFallbackModel(ctx.model?.id, modelConfig); - if (nextModelId) { - retryState.networkRetryCount = 0; - retryState.currentRetryModelId = undefined; - const modelToSet = resolveModelId(nextModelId, availableModels, ctx.model?.provider); - if (modelToSet) { - const ok = await pi.setModel(modelToSet, { persist: persistModelChanges }); - if (ok) { - ctx.ui.notify(`Model error${errorDetail}. Switched to fallback: ${nextModelId} and resuming.`, "warning"); - pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true }); - return; - } - } - } - } - } - - // Try restoring session model - const sessionModel = getAutoModeStartModel(); - if (sessionModel) { - if (ctx.model?.id !== sessionModel.id || ctx.model?.provider !== sessionModel.provider) { - const startModel = ctx.modelRegistry.getAvailable().find((m) => m.provider === sessionModel.provider && m.id === sessionModel.id); - if (startModel) { - const ok = await pi.setModel(startModel, { persist: persistModelChanges }); - if (ok) { - retryState.networkRetryCount = 0; - retryState.currentRetryModelId = undefined; - ctx.ui.notify(`Model error${errorDetail}. Restored session model: ${sessionModel.provider}/${sessionModel.id} and resuming.`, "warning"); - pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true }); - return; - } - } - } - } - } - - // --- Transient fallback: pause with auto-resume --- - if (isTransient(cls)) { - await pauseTransientWithBackoff(cls, pi, ctx, errorDetail, cls.kind === "rate-limit"); - return; - } - - // --- Permanent / unknown: pause indefinitely --- - await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi, { - message: `Provider error: ${errorDetail}`, - category: "provider", - isTransient: false, - }), { - isRateLimit: false, - isTransient: false, - retryAfterMs: 0, - }); - return; - } - - // ── Success path ───────────────────────────────────────────────────────── - try { - resetRetryState(retryState); - resolveAgentEnd(event); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Auto-mode error in agent_end handler: ${message}. Stopping auto-mode.`, "error"); - try { - await pauseAuto(ctx, pi); - } catch (e) { - logWarning("bootstrap", `pauseAuto failed in agent_end handler: ${(e as Error).message}`); - } - } -} diff --git a/src/resources/extensions/gsd/bootstrap/crash-log.ts b/src/resources/extensions/gsd/bootstrap/crash-log.ts deleted file mode 100644 index 919d1fcfa..000000000 --- a/src/resources/extensions/gsd/bootstrap/crash-log.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * crash-log.ts — Write crash diagnostics to ~/.gsd/crash/.log - * - * Zero cross-dependencies: only uses Node.js built-ins so it can be imported - * safely from uncaughtException / unhandledRejection handlers and from tests - * without pulling in the full extension dependency tree. - */ - -import { appendFileSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -/** - * Write a crash log to ~/.gsd/crash/.log (or $SF_HOME/crash/). - * Never throws — must be safe to call from any error handler. - */ -export function writeCrashLog(err: Error, source: string): void { - try { - const crashDir = join(process.env.SF_HOME ?? join(homedir(), ".gsd"), "crash"); - mkdirSync(crashDir, { recursive: true }); - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const logPath = join(crashDir, `${ts}.log`); - const lines = [ - `[forge] ${source}: ${err.message}`, - `timestamp: ${new Date().toISOString()}`, - `pid: ${process.pid}`, - err.stack ?? "(no stack trace available)", - "", - ]; - appendFileSync(logPath, lines.join("\n")); - } catch { /* never throw from crash handler */ } -} diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts deleted file mode 100644 index a3030d5e7..000000000 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ /dev/null @@ -1,1066 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { ExtensionAPI } from "@sf-run/pi-coding-agent"; -import { Text } from "@sf-run/pi-tui"; - -import { findMilestoneIds, nextMilestoneId, claimReservedId, getReservedMilestoneIds } from "../guided-flow.js"; -import { loadEffectiveGSDPreferences } from "../preferences.js"; -import { ensureDbOpen } from "./dynamic-tools.js"; -import { StringEnum } from "@sf-run/pi-ai"; -import { logError } from "../workflow-logger.js"; -import { getErrorMessage } from "../error-utils.js"; -import { - executeCompleteMilestone, - executePlanMilestone, - executePlanSlice, - executeReplanSlice, - executeReassessRoadmap, - executeSaveGateResult, - executeSliceComplete, - executeSummarySave, - executeTaskComplete, - executeValidateMilestone, -} from "../tools/workflow-tool-executors.js"; - -/** - * Register an alias tool that shares the same execute function as its canonical counterpart. - * The alias description and promptGuidelines direct the LLM to prefer the canonical name. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- toolDef shape matches ToolDefinition but typing it fully requires generics -function registerAlias(pi: ExtensionAPI, toolDef: any, aliasName: string, canonicalName: string): void { - pi.registerTool({ - ...toolDef, - name: aliasName, - description: toolDef.description + ` (alias for ${canonicalName} — prefer the canonical name)`, - promptGuidelines: [`Alias for ${canonicalName} — prefer the canonical name.`], - }); -} - -export function registerDbTools(pi: ExtensionAPI): void { - // ─── gsd_decision_save (formerly gsd_save_decision) ───────────────────── - - const decisionSaveExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: SF database is not available. Cannot save decision." }], - details: { operation: "save_decision", error: "db_unavailable" } as any, - }; - } - try { - const { saveDecisionToDb } = await import("../db-writer.js"); - const { id } = await saveDecisionToDb( - { - scope: params.scope, - decision: params.decision, - choice: params.choice, - rationale: params.rationale, - revisable: params.revisable, - when_context: params.when_context, - made_by: params.made_by, - }, - process.cwd(), - ); - return { - content: [{ type: "text" as const, text: `Saved decision ${id}` }], - details: { operation: "save_decision", id } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `gsd_decision_save tool failed: ${msg}`, { tool: "gsd_decision_save", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }], - details: { operation: "save_decision", error: msg } as any, - }; - } - }; - - const decisionSaveTool = { - name: "gsd_decision_save", - label: "Save Decision", - description: - "Record a project decision to the SF database and regenerate DECISIONS.md. " + - "Decision IDs are auto-assigned — never provide an ID manually.", - promptSnippet: "Record a project decision to the SF database (auto-assigns ID, regenerates DECISIONS.md)", - promptGuidelines: [ - "Use gsd_decision_save when recording an architectural, pattern, library, or observability decision.", - "Decision IDs are auto-assigned (D001, D002, ...) — never guess or provide an ID.", - "All fields except revisable, when_context, and made_by are required.", - "The tool writes to the DB and regenerates .gsd/DECISIONS.md automatically.", - "Set made_by to 'human' when the user explicitly directed the decision, 'agent' when the LLM chose autonomously (default), or 'collaborative' when it was discussed and agreed together.", - ], - parameters: Type.Object({ - scope: Type.String({ description: "Scope of the decision (e.g. 'architecture', 'library', 'observability')" }), - decision: Type.String({ description: "What is being decided" }), - choice: Type.String({ description: "The choice made" }), - rationale: Type.String({ description: "Why this choice was made" }), - revisable: Type.Optional(Type.String({ description: "Whether this can be revisited (default: 'Yes')" })), - when_context: Type.Optional(Type.String({ description: "When/context for the decision (e.g. milestone ID)" })), - made_by: Type.Optional(Type.Union([ - Type.Literal("human"), - Type.Literal("agent"), - Type.Literal("collaborative"), - ], { description: "Who made this decision: 'human' (user directed), 'agent' (LLM decided autonomously), or 'collaborative' (discussed and agreed). Default: 'agent'" })), - }), - execute: decisionSaveExecute, - renderCall(args: any, theme: any) { - let text = theme.fg("toolTitle", theme.bold("decision_save ")); - if (args.scope) text += theme.fg("accent", `[${args.scope}] `); - if (args.decision) text += theme.fg("muted", args.decision); - if (args.choice) text += theme.fg("dim", ` — ${args.choice}`); - return new Text(text, 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - let text = theme.fg("success", `Decision ${d?.id ?? ""} saved`); - if (d?.id) text += theme.fg("dim", ` → DECISIONS.md`); - return new Text(text, 0, 0); - }, - }; - - pi.registerTool(decisionSaveTool); - registerAlias(pi, decisionSaveTool, "gsd_save_decision", "gsd_decision_save"); - - // ─── gsd_requirement_update (formerly gsd_update_requirement) ─────────── - - const requirementUpdateExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: SF database is not available. Cannot update requirement." }], - details: { operation: "update_requirement", id: params.id, error: "db_unavailable" } as any, - }; - } - try { - const { updateRequirementInDb } = await import("../db-writer.js"); - const updates: Record = {}; - if (params.status !== undefined) updates.status = params.status; - if (params.validation !== undefined) updates.validation = params.validation; - if (params.notes !== undefined) updates.notes = params.notes; - if (params.description !== undefined) updates.description = params.description; - if (params.primary_owner !== undefined) updates.primary_owner = params.primary_owner; - if (params.supporting_slices !== undefined) updates.supporting_slices = params.supporting_slices; - await updateRequirementInDb(params.id, updates, process.cwd()); - return { - content: [{ type: "text" as const, text: `Updated requirement ${params.id}` }], - details: { operation: "update_requirement", id: params.id } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `gsd_requirement_update tool failed: ${msg}`, { tool: "gsd_requirement_update", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }], - details: { operation: "update_requirement", id: params.id, error: msg } as any, - }; - } - }; - - const requirementUpdateTool = { - name: "gsd_requirement_update", - label: "Update Requirement", - description: - "Update an existing requirement in the SF database and regenerate REQUIREMENTS.md. " + - "Provide the requirement ID (e.g. R001) and any fields to update.", - promptSnippet: "Update an existing SF requirement by ID (regenerates REQUIREMENTS.md)", - promptGuidelines: [ - "Use gsd_requirement_update to change status, validation, notes, or other fields on an existing requirement.", - "The id parameter is required — it must be an existing RXXX identifier.", - "All other fields are optional — only provided fields are updated.", - "The tool verifies the requirement exists before updating.", - ], - parameters: Type.Object({ - id: Type.String({ description: "The requirement ID (e.g. R001, R014)" }), - status: Type.Optional(Type.String({ description: "New status (e.g. 'active', 'validated', 'deferred')" })), - validation: Type.Optional(Type.String({ description: "Validation criteria or proof" })), - notes: Type.Optional(Type.String({ description: "Additional notes" })), - description: Type.Optional(Type.String({ description: "Updated description" })), - primary_owner: Type.Optional(Type.String({ description: "Primary owning slice" })), - supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })), - }), - execute: requirementUpdateExecute, - renderCall(args: any, theme: any) { - let text = theme.fg("toolTitle", theme.bold("requirement_update ")); - if (args.id) text += theme.fg("accent", args.id); - const fields = ["status", "validation", "notes", "description"].filter((f) => args[f]); - if (fields.length > 0) text += theme.fg("dim", ` (${fields.join(", ")})`); - return new Text(text, 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - let text = theme.fg("success", `Requirement ${d?.id ?? ""} updated`); - text += theme.fg("dim", ` → REQUIREMENTS.md`); - return new Text(text, 0, 0); - }, - }; - - pi.registerTool(requirementUpdateTool); - registerAlias(pi, requirementUpdateTool, "gsd_update_requirement", "gsd_requirement_update"); - - // ─── gsd_requirement_save ───────────────────────────────────────────── - - const requirementSaveExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: SF database is not available. Cannot save requirement." }], - details: { operation: "save_requirement", error: "db_unavailable" } as any, - }; - } - try { - const { saveRequirementToDb } = await import("../db-writer.js"); - const result = await saveRequirementToDb( - { - class: params.class, - status: params.status, - description: params.description, - why: params.why, - source: params.source, - primary_owner: params.primary_owner, - supporting_slices: params.supporting_slices, - validation: params.validation, - notes: params.notes, - }, - process.cwd(), - ); - return { - content: [{ type: "text" as const, text: `Saved requirement ${result.id}` }], - details: { operation: "save_requirement", id: result.id } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `gsd_requirement_save tool failed: ${msg}`, { tool: "gsd_requirement_save", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error saving requirement: ${msg}` }], - details: { operation: "save_requirement", error: msg } as any, - }; - } - }; - - const requirementSaveTool = { - name: "gsd_requirement_save", - label: "Save Requirement", - description: - "Record a new requirement to the SF database and regenerate REQUIREMENTS.md. " + - "Requirement IDs are auto-assigned — never provide an ID manually.", - promptSnippet: "Record a new SF requirement to the database (auto-assigns ID, regenerates REQUIREMENTS.md)", - promptGuidelines: [ - "Use gsd_requirement_save when recording a new functional, non-functional, or operational requirement.", - "Requirement IDs are auto-assigned (R001, R002, ...) — never guess or provide an ID.", - "class, description, why, and source are required. All other fields are optional.", - "The tool writes to the DB and regenerates .gsd/REQUIREMENTS.md automatically.", - ], - parameters: Type.Object({ - class: Type.String({ description: "Requirement class (e.g. 'functional', 'non-functional', 'operational')" }), - description: Type.String({ description: "Short description of the requirement" }), - why: Type.String({ description: "Why this requirement matters" }), - source: Type.String({ description: "Origin of the requirement (e.g. 'user-research', 'design', 'M001')" }), - status: Type.Optional(Type.String({ description: "Status (default: 'active')" })), - primary_owner: Type.Optional(Type.String({ description: "Primary owning slice" })), - supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })), - validation: Type.Optional(Type.String({ description: "Validation criteria" })), - notes: Type.Optional(Type.String({ description: "Additional notes" })), - }), - execute: requirementSaveExecute, - renderCall(args: any, theme: any) { - let text = theme.fg("toolTitle", theme.bold("requirement_save ")); - if (args.class) text += theme.fg("accent", `[${args.class}] `); - if (args.description) text += theme.fg("muted", args.description); - return new Text(text, 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - let text = theme.fg("success", `Requirement ${d?.id ?? ""} saved`); - text += theme.fg("dim", ` → REQUIREMENTS.md`); - return new Text(text, 0, 0); - }, - }; - - pi.registerTool(requirementSaveTool); - registerAlias(pi, requirementSaveTool, "gsd_save_requirement", "gsd_requirement_save"); - - // ─── gsd_summary_save (formerly gsd_save_summary) ────────────────────── - - const summarySaveExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeSummarySave(params, process.cwd()); - }; - - const summarySaveTool = { - name: "gsd_summary_save", - label: "Save Summary", - description: - "Save a summary, research, context, or assessment artifact to the SF database and write it to disk. " + - "Computes the file path from milestone/slice/task IDs automatically.", - promptSnippet: "Save a SF artifact (summary/research/context/assessment) to DB and disk", - promptGuidelines: [ - "Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT).", - "milestone_id is required. slice_id and task_id are optional — they determine the file path.", - "The tool computes the relative path automatically: milestones/M001/M001-SUMMARY.md, milestones/M001/slices/S01/S01-SUMMARY.md, etc.", - "artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT.", - "Use CONTEXT-DRAFT for incremental draft persistence; use CONTEXT for the final milestone context after depth verification.", - ], - parameters: Type.Object({ - milestone_id: Type.String({ description: "Milestone ID (e.g. M001)" }), - slice_id: Type.Optional(Type.String({ description: "Slice ID (e.g. S01)" })), - task_id: Type.Optional(Type.String({ description: "Task ID (e.g. T01)" })), - artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT" }), - content: Type.String({ description: "The full markdown content of the artifact" }), - }), - execute: summarySaveExecute, - renderCall(args: any, theme: any) { - let text = theme.fg("toolTitle", theme.bold("summary_save ")); - if (args.artifact_type) text += theme.fg("accent", args.artifact_type); - const path = [args.milestone_id, args.slice_id, args.task_id].filter(Boolean).join("/"); - if (path) text += theme.fg("dim", ` ${path}`); - return new Text(text, 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - let text = theme.fg("success", `${d?.artifact_type ?? "Artifact"} saved`); - if (d?.path) text += theme.fg("dim", ` → ${d.path}`); - return new Text(text, 0, 0); - }, - }; - - pi.registerTool(summarySaveTool); - registerAlias(pi, summarySaveTool, "gsd_save_summary", "gsd_summary_save"); - - // ─── gsd_milestone_generate_id (formerly gsd_generate_milestone_id) ──── - - const milestoneGenerateIdExecute = async (_toolCallId: string, _params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - try { - // Claim a reserved ID if the guided-flow already previewed one to the user. - // This guarantees the ID shown in the UI matches the one materialised on disk. - const reserved = claimReservedId(); - if (reserved) { - await ensureMilestoneDbRow(reserved); - return { - content: [{ type: "text" as const, text: reserved }], - details: { operation: "generate_milestone_id", id: reserved, source: "reserved" } as any, - }; - } - - const basePath = process.cwd(); - const existingIds = findMilestoneIds(basePath); - const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])]; - const newId = nextMilestoneId(allIds, uniqueEnabled); - await ensureMilestoneDbRow(newId); - return { - content: [{ type: "text" as const, text: newId }], - details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, uniqueEnabled } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }], - details: { operation: "generate_milestone_id", error: msg } as any, - }; - } - }; - - /** - * Insert a minimal DB row for a milestone ID so it's visible to the state - * machine. Uses INSERT OR IGNORE — safe to call even if gsd_plan_milestone - * later writes the full row. Silently skips if the DB isn't available yet - * (pre-migration). - */ - async function ensureMilestoneDbRow(milestoneId: string): Promise { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) return; - try { - const { insertMilestone } = await import("../gsd-db.js"); - insertMilestone({ id: milestoneId, status: "queued" }); - } catch (e) { - logError("tool", `insertMilestone failed for ${milestoneId}: ${(e as Error).message}`); - } - } - - const milestoneGenerateIdTool = { - name: "gsd_milestone_generate_id", - label: "Generate Milestone ID", - description: - "Generate the next milestone ID for a new SF milestone. " + - "Scans existing milestones on disk and respects the unique_milestone_ids preference. " + - "Always use this tool when creating a new milestone — never invent milestone IDs manually.", - promptSnippet: "Generate a valid milestone ID (respects unique_milestone_ids preference)", - promptGuidelines: [ - "ALWAYS call gsd_milestone_generate_id before creating a new milestone directory or writing milestone files.", - "Never invent or hardcode milestone IDs like M001, M002 — always use this tool.", - "Call it once per milestone you need to create. For multi-milestone projects, call it once for each milestone in sequence.", - "The tool returns the correct format based on project preferences (e.g. M001 or M001-r5jzab).", - ], - parameters: Type.Object({}), - execute: milestoneGenerateIdExecute, - renderCall(_args: any, theme: any) { - return new Text(theme.fg("toolTitle", theme.bold("milestone_generate_id")), 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - let text = theme.fg("success", `Generated ${d?.id ?? "ID"}`); - if (d?.source === "reserved") text += theme.fg("dim", " (reserved)"); - return new Text(text, 0, 0); - }, - }; - - pi.registerTool(milestoneGenerateIdTool); - registerAlias(pi, milestoneGenerateIdTool, "gsd_generate_milestone_id", "gsd_milestone_generate_id"); - - // ─── gsd_plan_milestone (gsd_milestone_plan alias) ───────────────────── - - const planMilestoneExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executePlanMilestone(params, process.cwd()); - }; - - const planMilestoneTool = { - name: "gsd_plan_milestone", - label: "Plan Milestone", - description: - "Write milestone planning state to the SF database, render ROADMAP.md from DB, and clear caches after a successful render.", - promptSnippet: "Plan a milestone via DB write + roadmap render + cache invalidation", - promptGuidelines: [ - "Use gsd_plan_milestone for milestone planning instead of writing ROADMAP.md directly.", - "Keep parameters flat and provide the full milestone planning payload, including slices.", - "The tool validates input, writes milestone and slice planning data transactionally, renders ROADMAP.md from DB, and clears both state and parse caches after success.", - "Use the canonical name gsd_plan_milestone; gsd_milestone_plan is only an alias.", - ], - parameters: Type.Object({ - // ── Core identification + content (required) ────────────────────── - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - title: Type.String({ description: "Milestone title" }), - vision: Type.String({ description: "Milestone vision" }), - slices: Type.Array(Type.Object({ - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - title: Type.String({ description: "Slice title" }), - risk: Type.String({ description: "Slice risk" }), - depends: Type.Array(Type.String(), { description: "Slice dependency IDs" }), - demo: Type.String({ description: "Roadmap demo text / After this" }), - goal: Type.String({ description: "Slice goal" }), - successCriteria: Type.String({ description: "Slice success criteria block" }), - proofLevel: Type.String({ description: "Slice proof level" }), - integrationClosure: Type.String({ description: "Slice integration closure" }), - observabilityImpact: Type.String({ description: "Slice observability impact" }), - }), { description: "Planned slices for the milestone" }), - // ── Enrichment metadata (optional — defaults to empty) ──────────── - status: Type.Optional(Type.String({ description: "Milestone status (defaults to active)" })), - dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Milestone dependencies" })), - successCriteria: Type.Optional(Type.Array(Type.String(), { description: "Top-level success criteria bullets" })), - keyRisks: Type.Optional(Type.Array(Type.Object({ - risk: Type.String({ description: "Risk statement" }), - whyItMatters: Type.String({ description: "Why the risk matters" }), - }), { description: "Structured risk entries" })), - proofStrategy: Type.Optional(Type.Array(Type.Object({ - riskOrUnknown: Type.String({ description: "Risk or unknown to retire" }), - retireIn: Type.String({ description: "Where it will be retired" }), - whatWillBeProven: Type.String({ description: "What proof will be produced" }), - }), { description: "Structured proof strategy entries" })), - verificationContract: Type.Optional(Type.String({ description: "Verification contract text" })), - verificationIntegration: Type.Optional(Type.String({ description: "Integration verification text" })), - verificationOperational: Type.Optional(Type.String({ description: "Operational verification text" })), - verificationUat: Type.Optional(Type.String({ description: "UAT verification text" })), - definitionOfDone: Type.Optional(Type.Array(Type.String(), { description: "Definition of done bullets" })), - requirementCoverage: Type.Optional(Type.String({ description: "Requirement coverage text" })), - boundaryMapMarkdown: Type.Optional(Type.String({ description: "Boundary map markdown block" })), - }), - execute: planMilestoneExecute, - }; - - pi.registerTool(planMilestoneTool); - registerAlias(pi, planMilestoneTool, "gsd_milestone_plan", "gsd_plan_milestone"); - - // ─── gsd_plan_slice (gsd_slice_plan alias) ───────────────────────────── - - const planSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executePlanSlice(params, process.cwd()); - }; - - const planSliceTool = { - name: "gsd_plan_slice", - label: "Plan Slice", - description: - "Write slice planning state to the SF database, render S##-PLAN.md plus task PLAN artifacts from DB, and clear caches after a successful render.", - promptSnippet: "Plan a slice via DB write + PLAN render + cache invalidation", - promptGuidelines: [ - "Use gsd_plan_slice for slice planning instead of writing S##-PLAN.md or task PLAN files directly.", - "Keep parameters flat and provide the full slice planning payload, including tasks.", - "The tool validates input, requires an existing parent slice, writes slice/task planning data, renders PLAN.md and task plan files from DB, and clears both state and parse caches after success.", - "Use the canonical name gsd_plan_slice; gsd_slice_plan is only an alias.", - ], - parameters: Type.Object({ - // ── Core identification + content (required) ────────────────────── - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - goal: Type.String({ description: "Slice goal" }), - tasks: Type.Array(Type.Object({ - taskId: Type.String({ description: "Task ID (e.g. T01)" }), - title: Type.String({ description: "Task title" }), - description: Type.String({ description: "Task description / steps block" }), - estimate: Type.String({ description: "Task estimate string" }), - files: Type.Array(Type.String(), { description: "Files likely touched" }), - verify: Type.String({ description: "Verification command or block" }), - inputs: Type.Array(Type.String(), { description: "Input files or references" }), - expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }), - observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })), - }), { description: "Planned tasks for the slice" }), - // ── Enrichment metadata (optional — defaults to empty) ──────────── - successCriteria: Type.Optional(Type.String({ description: "Slice success criteria block" })), - proofLevel: Type.Optional(Type.String({ description: "Slice proof level" })), - integrationClosure: Type.Optional(Type.String({ description: "Slice integration closure" })), - observabilityImpact: Type.Optional(Type.String({ description: "Slice observability impact" })), - }), - execute: planSliceExecute, - }; - - pi.registerTool(planSliceTool); - registerAlias(pi, planSliceTool, "gsd_slice_plan", "gsd_plan_slice"); - - // ─── gsd_plan_task (gsd_task_plan alias) ─────────────────────────────── - - const planTaskExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: SF database is not available. Cannot plan task." }], - details: { operation: "plan_task", error: "db_unavailable" } as any, - }; - } - try { - const { handlePlanTask } = await import("../tools/plan-task.js"); - const result = await handlePlanTask(params, process.cwd()); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: `Error planning task: ${result.error}` }], - details: { operation: "plan_task", error: result.error } as any, - }; - } - return { - content: [{ type: "text" as const, text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }], - details: { - operation: "plan_task", - milestoneId: result.milestoneId, - sliceId: result.sliceId, - taskId: result.taskId, - taskPlanPath: result.taskPlanPath, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `plan_task tool failed: ${msg}`, { tool: "gsd_plan_task", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error planning task: ${msg}` }], - details: { operation: "plan_task", error: msg } as any, - }; - } - }; - - const planTaskTool = { - name: "gsd_plan_task", - label: "Plan Task", - description: - "Write task planning state to the SF database, render tasks/T##-PLAN.md from DB, and clear caches after a successful render.", - promptSnippet: "Plan a task via DB write + task PLAN render + cache invalidation", - promptGuidelines: [ - "Use gsd_plan_task for task planning instead of writing tasks/T##-PLAN.md directly.", - "Keep parameters flat and provide the full task planning payload.", - "The tool validates input, requires an existing parent slice, writes task planning data, renders the task PLAN file from DB, and clears both state and parse caches after success.", - "Use the canonical name gsd_plan_task; gsd_task_plan is only an alias.", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - taskId: Type.String({ description: "Task ID (e.g. T01)" }), - title: Type.String({ description: "Task title" }), - description: Type.String({ description: "Task description / steps block" }), - estimate: Type.String({ description: "Task estimate string" }), - files: Type.Array(Type.String(), { description: "Files likely touched" }), - verify: Type.String({ description: "Verification command or block" }), - inputs: Type.Array(Type.String(), { description: "Input files or references" }), - expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }), - observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })), - }), - execute: planTaskExecute, - }; - - pi.registerTool(planTaskTool); - registerAlias(pi, planTaskTool, "gsd_task_plan", "gsd_plan_task"); - - // ─── gsd_task_complete (gsd_complete_task alias) ──────────────────────── - - const taskCompleteExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeTaskComplete(params, process.cwd()); - }; - - const taskCompleteTool = { - name: "gsd_task_complete", - label: "Complete Task", - description: - "Record a completed task to the SF database, render a SUMMARY.md to disk, and toggle the plan checkbox — all in one atomic operation. " + - "Writes the task row inside a transaction, then performs filesystem writes outside the transaction.", - promptSnippet: "Complete a SF task (DB write + summary render + checkbox toggle)", - promptGuidelines: [ - "Use gsd_task_complete (or gsd_complete_task) when a task is finished and needs to be recorded.", - "All string fields are required. verificationEvidence is an array of objects with command, exitCode, verdict, durationMs.", - "The tool validates required fields and returns an error message if any are missing.", - "On success, returns the summaryPath where the SUMMARY.md was written.", - "Idempotent — calling with the same params twice will upsert (INSERT OR REPLACE) without error.", - ], - parameters: Type.Object({ - // ── Core identification + content (required) ────────────────────── - taskId: Type.String({ description: "Task ID (e.g. T01)" }), - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - oneLiner: Type.String({ description: "One-line summary of what was accomplished" }), - narrative: Type.String({ description: "Detailed narrative of what happened during the task" }), - verification: Type.String({ description: "What was verified and how — commands run, tests passed, behavior confirmed" }), - // ── Enrichment metadata (optional — defaults to empty) ──────────── - deviations: Type.Optional(Type.String({ description: "Deviations from the task plan, or 'None.'" })), - knownIssues: Type.Optional(Type.String({ description: "Known issues discovered but not fixed, or 'None.'" })), - keyFiles: Type.Optional(Type.Array(Type.String(), { description: "List of key files created or modified" })), - keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "List of key decisions made during this task" })), - blockerDiscovered: Type.Optional(Type.Boolean({ description: "Whether a plan-invalidating blocker was discovered" })), - verificationEvidence: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - command: Type.String({ description: "Verification command that was run" }), - exitCode: Type.Number({ description: "Exit code of the command" }), - verdict: Type.String({ description: "Pass/fail verdict (e.g. '✅ pass', '❌ fail')" }), - durationMs: Type.Number({ description: "Duration of the command in milliseconds" }), - }), - Type.String({ description: "Fallback: verification summary string" }), - ]), - { description: "Array of verification evidence entries" }, - )), - }), - execute: taskCompleteExecute, - }; - - pi.registerTool(taskCompleteTool); - registerAlias(pi, taskCompleteTool, "gsd_complete_task", "gsd_task_complete"); - - // ─── gsd_slice_complete (gsd_complete_slice alias) ───────────────────── - - const sliceCompleteExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeSliceComplete(params, process.cwd()); - }; - - const sliceCompleteTool = { - name: "gsd_slice_complete", - label: "Complete Slice", - description: - "Record a completed slice to the SF database, render SUMMARY.md + UAT.md to disk, and toggle the roadmap checkbox — all in one atomic operation. " + - "Validates all tasks are complete before proceeding. Writes the slice row inside a transaction, then performs filesystem writes outside the transaction.", - promptSnippet: "Complete a SF slice (DB write + summary/UAT render + roadmap checkbox toggle)", - promptGuidelines: [ - "Use gsd_slice_complete (or gsd_complete_slice) when all tasks in a slice are finished and the slice needs to be recorded.", - "All tasks in the slice must have status 'complete' — the handler validates this before proceeding.", - "On success, returns summaryPath and uatPath where the files were written.", - "Idempotent — calling with the same params twice will not crash.", - ], - parameters: Type.Object({ - // ── Core identification + content (required) ────────────────────── - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - sliceTitle: Type.String({ description: "Title of the slice" }), - oneLiner: Type.String({ description: "One-line summary of what the slice accomplished" }), - narrative: Type.String({ description: "Detailed narrative of what happened across all tasks" }), - verification: Type.String({ description: "What was verified across all tasks" }), - uatContent: Type.String({ description: "UAT test content (markdown body)" }), - // ── Enrichment metadata (optional — defaults to empty) ──────────── - deviations: Type.Optional(Type.String({ description: "Deviations from the slice plan, or 'None.'" })), - knownLimitations: Type.Optional(Type.String({ description: "Known limitations or gaps, or 'None.'" })), - followUps: Type.Optional(Type.String({ description: "Follow-up work discovered during execution, or 'None.'" })), - keyFiles: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key files created or modified" })), - keyDecisions: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Key decisions made during this slice" })), - patternsEstablished: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Patterns established by this slice" })), - observabilitySurfaces: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Observability surfaces added" })), - provides: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "What this slice provides to downstream slices" })), - requirementsSurfaced: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "New requirements surfaced" })), - drillDownPaths: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Paths to task summaries for drill-down" })), - affects: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Downstream slices affected" })), - requirementsAdvanced: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - how: Type.String({ description: "How it was advanced" }), - }), - Type.String({ description: "Fallback: 'ID — how' string" }), - ]), - { description: "Requirements advanced by this slice" }, - )), - requirementsValidated: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - proof: Type.String({ description: "What proof validates it" }), - }), - Type.String({ description: "Fallback: 'ID — proof' string" }), - ]), - { description: "Requirements validated by this slice" }, - )), - requirementsInvalidated: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - id: Type.String({ description: "Requirement ID" }), - what: Type.String({ description: "What changed" }), - }), - Type.String({ description: "Fallback: 'ID — what' string" }), - ]), - { description: "Requirements invalidated or re-scoped" }, - )), - filesModified: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - path: Type.String({ description: "File path" }), - description: Type.String({ description: "What changed" }), - }), - Type.String({ description: "Fallback: file path string" }), - ]), - { description: "Files modified with descriptions" }, - )), - requires: Type.Optional(Type.Array( - Type.Union([ - Type.Object({ - slice: Type.String({ description: "Dependency slice ID" }), - provides: Type.String({ description: "What was consumed from it" }), - }), - Type.String({ description: "Fallback: slice ID string" }), - ]), - { description: "Upstream slice dependencies consumed" }, - )), - }), - execute: sliceCompleteExecute, - }; - - pi.registerTool(sliceCompleteTool); - registerAlias(pi, sliceCompleteTool, "gsd_complete_slice", "gsd_slice_complete"); - - // ─── gsd_skip_slice (#3477 / #3487) ─────────────────────────────────── - - const skipSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: SF database is not available. Cannot skip slice." }], - details: { operation: "skip_slice", error: "db_unavailable" } as any, - }; - } - try { - const { getSlice, updateSliceStatus } = await import("../gsd-db.js"); - const { invalidateStateCache } = await import("../state.js"); - - const slice = getSlice(params.milestoneId, params.sliceId); - if (!slice) { - return { - content: [{ type: "text" as const, text: `Error: Slice ${params.sliceId} not found in milestone ${params.milestoneId}` }], - details: { operation: "skip_slice", error: "slice_not_found" } as any, - }; - } - - if (slice.status === "complete" || slice.status === "done") { - return { - content: [{ type: "text" as const, text: `Error: Slice ${params.sliceId} is already complete — cannot skip.` }], - details: { operation: "skip_slice", error: "already_complete" } as any, - }; - } - - if (slice.status === "skipped") { - return { - content: [{ type: "text" as const, text: `Slice ${params.sliceId} is already skipped.` }], - details: { operation: "skip_slice", sliceId: params.sliceId, milestoneId: params.milestoneId } as any, - }; - } - - updateSliceStatus(params.milestoneId, params.sliceId, "skipped"); - invalidateStateCache(); - - // Rebuild STATE.md so it reflects the skip immediately (#3477). - // Without this, /gsd auto reads stale STATE.md and resumes the skipped slice. - try { - const basePath = process.cwd(); - const { rebuildState } = await import("../doctor.js"); - await rebuildState(basePath); - } catch (err) { - logError("tool", `skip_slice rebuildState failed: ${(err as Error).message}`, { tool: "gsd_skip_slice" }); - } - - return { - content: [{ type: "text" as const, text: `Skipped slice ${params.sliceId} (${params.milestoneId}). Reason: ${params.reason ?? "User-directed skip"}. Auto-mode will advance past this slice.` }], - details: { - operation: "skip_slice", - sliceId: params.sliceId, - milestoneId: params.milestoneId, - reason: params.reason, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `skip_slice tool failed: ${msg}`, { tool: "gsd_skip_slice", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error skipping slice: ${msg}` }], - details: { operation: "skip_slice", error: msg } as any, - }; - } - }; - - pi.registerTool({ - name: "gsd_skip_slice", - label: "Skip Slice", - description: - "Mark a slice as skipped so auto-mode advances past it without executing. " + - "The slice data is preserved for reference. The state machine treats skipped slices like completed ones for dependency satisfaction.", - promptSnippet: "Skip a SF slice (mark as skipped, auto-mode will advance past it)", - promptGuidelines: [ - "Use gsd_skip_slice when a slice should be bypassed — descoped, superseded, or no longer relevant.", - "Cannot skip a slice that is already complete.", - "Skipped slices satisfy downstream dependencies just like completed slices.", - ], - parameters: Type.Object({ - sliceId: Type.String({ description: "Slice ID (e.g. S02)" }), - milestoneId: Type.String({ description: "Milestone ID (e.g. M003)" }), - reason: Type.Optional(Type.String({ description: "Reason for skipping this slice" })), - }), - execute: skipSliceExecute, - }); - - // ─── gsd_complete_milestone ──────────────────────────────────────────── - - const milestoneCompleteExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeCompleteMilestone(params, process.cwd()); - }; - - const milestoneCompleteTool = { - name: "gsd_complete_milestone", - label: "Complete Milestone", - description: - "Record a completed milestone to the SF database, render MILESTONE-SUMMARY.md to disk — all in one atomic operation. " + - "Validates all slices are complete before proceeding.", - promptSnippet: "Complete a SF milestone (DB write + summary render)", - promptGuidelines: [ - "Use gsd_complete_milestone when all slices in a milestone are finished and the milestone needs to be recorded.", - "All slices in the milestone must have status 'complete' — the handler validates this before proceeding.", - "verificationPassed must be explicitly set to true — the handler rejects completion if verification did not pass.", - "On success, returns summaryPath where the MILESTONE-SUMMARY.md was written.", - ], - parameters: Type.Object({ - // ── Core identification + content (required) ────────────────────── - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - title: Type.String({ description: "Milestone title" }), - oneLiner: Type.String({ description: "One-sentence summary of what the milestone achieved" }), - narrative: Type.String({ description: "Detailed narrative of what happened during the milestone" }), - verificationPassed: Type.Boolean({ description: "Must be true — confirms that code change verification, success criteria, and definition of done checks all passed before completion" }), - // ── Enrichment metadata (optional — defaults to empty) ──────────── - successCriteriaResults: Type.Optional(Type.String({ description: "Markdown detailing how each success criterion was met or not met" })), - definitionOfDoneResults: Type.Optional(Type.String({ description: "Markdown detailing how each definition-of-done item was met" })), - requirementOutcomes: Type.Optional(Type.String({ description: "Markdown detailing requirement status transitions with evidence" })), - keyDecisions: Type.Optional(Type.Array(Type.String(), { description: "Key architectural/pattern decisions made during the milestone" })), - keyFiles: Type.Optional(Type.Array(Type.String(), { description: "Key files created or modified during the milestone" })), - lessonsLearned: Type.Optional(Type.Array(Type.String(), { description: "Lessons learned during the milestone" })), - followUps: Type.Optional(Type.String({ description: "Follow-up items for future milestones" })), - deviations: Type.Optional(Type.String({ description: "Deviations from the original plan" })), - }), - execute: milestoneCompleteExecute, - }; - - pi.registerTool(milestoneCompleteTool); - registerAlias(pi, milestoneCompleteTool, "gsd_milestone_complete", "gsd_complete_milestone"); - - // ─── gsd_validate_milestone (gsd_milestone_validate alias) ───────────── - - const milestoneValidateExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeValidateMilestone(params, process.cwd()); - }; - - const milestoneValidateTool = { - name: "gsd_validate_milestone", - label: "Validate Milestone", - description: - "Validate a milestone before completion — persist validation results to the DB, render VALIDATION.md to disk. " + - "Records verdict (pass/needs-attention/needs-remediation) and rationale.", - promptSnippet: "Validate a SF milestone (DB write + VALIDATION.md render)", - promptGuidelines: [ - "Use gsd_validate_milestone when all slices are done and the milestone needs validation before completion.", - "Parameters: milestoneId, verdict, remediationRound, successCriteriaChecklist, sliceDeliveryAudit, crossSliceIntegration, requirementCoverage, verificationClasses (optional), verdictRationale, remediationPlan (optional).", - "If verdict is 'needs-remediation', also provide remediationPlan and use gsd_reassess_roadmap to add remediation slices to the roadmap.", - "On success, returns validationPath where VALIDATION.md was written.", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - verdict: StringEnum(["pass", "needs-attention", "needs-remediation"], { description: "Validation verdict" }), - remediationRound: Type.Number({ description: "Remediation round (0 for first validation)" }), - successCriteriaChecklist: Type.String({ description: "Markdown checklist of success criteria with pass/fail and evidence" }), - sliceDeliveryAudit: Type.String({ description: "Markdown table auditing each slice's claimed vs delivered output" }), - crossSliceIntegration: Type.String({ description: "Markdown describing any cross-slice boundary mismatches" }), - requirementCoverage: Type.String({ description: "Markdown describing any unaddressed requirements" }), - verificationClasses: Type.Optional(Type.String({ description: "Markdown describing verification class compliance and gaps" })), - verdictRationale: Type.String({ description: "Why this verdict was chosen" }), - remediationPlan: Type.Optional(Type.String({ description: "Remediation plan (required if verdict is needs-remediation)" })), - }), - execute: milestoneValidateExecute, - }; - - pi.registerTool(milestoneValidateTool); - registerAlias(pi, milestoneValidateTool, "gsd_milestone_validate", "gsd_validate_milestone"); - - // ─── gsd_replan_slice (gsd_slice_replan alias) ───────────────────────── - - const replanSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeReplanSlice(params, process.cwd()); - }; - - const replanSliceTool = { - name: "gsd_replan_slice", - label: "Replan Slice", - description: - "Replan a slice after a blocker is discovered. Structurally enforces preservation of completed tasks — " + - "mutations to completed task IDs are rejected with actionable error payloads. Writes replan history to DB, " + - "applies task mutations, re-renders PLAN.md, and renders REPLAN.md.", - promptSnippet: "Replan a SF slice with structural enforcement of completed tasks", - promptGuidelines: [ - "Use gsd_replan_slice (canonical) or gsd_slice_replan (alias) when a blocker is discovered and the slice plan needs rewriting.", - "The tool structurally enforces that completed tasks cannot be updated or removed — violations return specific error payloads naming the blocked task ID.", - "Parameters: milestoneId, sliceId, blockerTaskId, blockerDescription, whatChanged, updatedTasks (array), removedTaskIds (array).", - "updatedTasks items: taskId, title, description, estimate, files, verify, inputs, expectedOutput.", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - blockerTaskId: Type.String({ description: "Task ID that discovered the blocker" }), - blockerDescription: Type.String({ description: "Description of the blocker" }), - whatChanged: Type.String({ description: "Summary of what changed in the plan" }), - updatedTasks: Type.Array( - Type.Object({ - taskId: Type.String({ description: "Task ID (e.g. T01)" }), - title: Type.String({ description: "Task title" }), - description: Type.String({ description: "Task description / steps block" }), - estimate: Type.String({ description: "Task estimate string" }), - files: Type.Array(Type.String(), { description: "Files likely touched" }), - verify: Type.String({ description: "Verification command or block" }), - inputs: Type.Array(Type.String(), { description: "Input files or references" }), - expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }), - }), - { description: "Tasks to upsert (update existing or insert new)" }, - ), - removedTaskIds: Type.Array(Type.String(), { description: "Task IDs to remove from the slice" }), - }), - execute: replanSliceExecute, - }; - - pi.registerTool(replanSliceTool); - registerAlias(pi, replanSliceTool, "gsd_slice_replan", "gsd_replan_slice"); - - // ─── gsd_reassess_roadmap (gsd_roadmap_reassess alias) ───────────────── - - const reassessRoadmapExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeReassessRoadmap(params, process.cwd()); - }; - - const reassessRoadmapTool = { - name: "gsd_reassess_roadmap", - label: "Reassess Roadmap", - description: - "Reassess the milestone roadmap after a slice completes. Structurally enforces preservation of completed slices — " + - "mutations to completed slice IDs are rejected with actionable error payloads. Writes assessment to DB, " + - "applies slice mutations, re-renders ROADMAP.md, and renders ASSESSMENT.md.", - promptSnippet: "Reassess a SF roadmap with structural enforcement of completed slices", - promptGuidelines: [ - "Use gsd_reassess_roadmap (canonical) or gsd_roadmap_reassess (alias) after a slice completes to reassess the roadmap.", - "The tool structurally enforces that completed slices cannot be modified or removed — violations return specific error payloads naming the blocked slice ID.", - "Parameters: milestoneId, completedSliceId, verdict, assessment, sliceChanges (object with modified, added, removed arrays).", - "sliceChanges.modified items: sliceId, title, risk (optional), depends (optional), demo (optional).", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - completedSliceId: Type.String({ description: "Slice ID that just completed" }), - verdict: Type.String({ description: "Assessment verdict (e.g. 'roadmap-confirmed', 'roadmap-adjusted')" }), - assessment: Type.String({ description: "Assessment text explaining the decision" }), - sliceChanges: Type.Object({ - modified: Type.Array( - Type.Object({ - sliceId: Type.String({ description: "Slice ID to modify" }), - title: Type.String({ description: "Updated slice title" }), - risk: Type.Optional(Type.String({ description: "Updated risk level" })), - depends: Type.Optional(Type.Array(Type.String(), { description: "Updated dependencies" })), - demo: Type.Optional(Type.String({ description: "Updated demo text" })), - }), - { description: "Slices to modify" }, - ), - added: Type.Array( - Type.Object({ - sliceId: Type.String({ description: "New slice ID" }), - title: Type.String({ description: "New slice title" }), - risk: Type.Optional(Type.String({ description: "Risk level" })), - depends: Type.Optional(Type.Array(Type.String(), { description: "Dependencies" })), - demo: Type.Optional(Type.String({ description: "Demo text" })), - }), - { description: "New slices to add" }, - ), - removed: Type.Array(Type.String(), { description: "Slice IDs to remove" }), - }, { description: "Slice changes to apply" }), - }), - execute: reassessRoadmapExecute, - }; - - pi.registerTool(reassessRoadmapTool); - registerAlias(pi, reassessRoadmapTool, "gsd_roadmap_reassess", "gsd_reassess_roadmap"); - - // ─── gsd_save_gate_result ────────────────────────────────────────────── - - const saveGateResultExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - return executeSaveGateResult(params, process.cwd()); - }; - - const saveGateResultTool = { - name: "gsd_save_gate_result", - label: "Save Gate Result", - description: - "Save the result of a quality gate evaluation (Q3-Q8 or MV01-MV04) to the SF database. " + - "Called by gate evaluation sub-agents after analyzing a specific quality question.", - promptSnippet: "Save quality gate evaluation result (verdict, rationale, findings)", - promptGuidelines: [ - "Use gsd_save_gate_result after evaluating a quality gate question.", - "gateId must be one of: Q3, Q4, Q5, Q6, Q7, Q8, MV01, MV02, MV03, MV04.", - "verdict must be: pass (no concerns), flag (concerns found), or omitted (not applicable).", - "rationale should be a one-sentence justification for the verdict.", - "findings should contain detailed markdown analysis (or empty string if omitted).", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), - sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), - gateId: Type.String({ description: "Gate ID: Q3, Q4, Q5, Q6, Q7, Q8, MV01, MV02, MV03, or MV04" }), - taskId: Type.Optional(Type.String({ description: "Task ID for task-scoped gates (Q5/Q6/Q7)" })), - verdict: Type.String({ description: "pass, flag, or omitted" }), - rationale: Type.String({ description: "One-sentence justification" }), - findings: Type.Optional(Type.String({ description: "Detailed markdown findings" })), - }), - execute: saveGateResultExecute, - renderCall(args: any, theme: any) { - let text = theme.fg("toolTitle", theme.bold("save_gate_result ")); - text += theme.fg("accent", args.gateId ?? ""); - text += theme.fg("dim", ` → ${args.verdict ?? ""}`); - return new Text(text, 0, 0); - }, - renderResult(result: any, _options: any, theme: any) { - const d = result.details; - if (result.isError || d?.error) { - return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); - } - const color = d?.verdict === "flag" ? "warning" : "success"; - return new Text(theme.fg(color, `${d?.gateId}: ${d?.verdict}`), 0, 0); - }, - }; - - pi.registerTool(saveGateResultTool); -} diff --git a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts b/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts deleted file mode 100644 index ec57774dc..000000000 --- a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { existsSync } from "node:fs"; -import { join, sep } from "node:path"; - -import type { ExtensionAPI } from "@sf-run/pi-coding-agent"; -import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@sf-run/pi-coding-agent"; - -import { DEFAULT_BASH_TIMEOUT_SECS } from "../constants.js"; -import { setLogBasePath, logWarning } from "../workflow-logger.js"; - -/** - * Resolve the correct DB path for the current working directory. - * If `basePath` is inside a `.gsd/worktrees//` directory, returns - * the project root's `.gsd/gsd.db` (shared WAL — R012). Otherwise - * returns `/.gsd/gsd.db`. - */ -export function resolveProjectRootDbPath(basePath: string): string { - // Detect worktree: look for `.gsd/worktrees/` in the path segments. - // A worktree path looks like: /project/root/.gsd/worktrees/M001/... - // We need to resolve back to /project/root/.gsd/gsd.db - const marker = `${sep}.gsd${sep}worktrees${sep}`; - const idx = basePath.indexOf(marker); - if (idx !== -1) { - const projectRoot = basePath.slice(0, idx); - return join(projectRoot, ".gsd", "gsd.db"); - } - - // Also handle forward-slash paths on all platforms - const fwdMarker = "/.gsd/worktrees/"; - const fwdIdx = basePath.indexOf(fwdMarker); - if (fwdIdx !== -1) { - const projectRoot = basePath.slice(0, fwdIdx); - return join(projectRoot, ".gsd", "gsd.db"); - } - - // External-state layout: ~/.gsd/projects//worktrees//... - // Resolve to ~/.gsd/projects//gsd.db (the canonical project DB) (#2952). - // Must be checked before the generic symlink-resolved handler: both match - // /.gsd/projects//worktrees/ but require different resolution targets. - const extRe = /[/\\]\.gsd[/\\]projects[/\\][a-f0-9]+[/\\]worktrees(?:[/\\]|$)/; - const extMatch = extRe.exec(basePath); - if (extMatch) { - const matchStr = extMatch[0]; - // Find the "/worktrees" portion within the match and slice up to it - const wtIdx = matchStr.search(/[/\\]worktrees(?:[/\\]|$)/); - const projectStateRoot = basePath.slice(0, extMatch.index + wtIdx); - return join(projectStateRoot, "gsd.db"); - } - - // Symlink-resolved layout: /.gsd/projects//worktrees/M001/... - // The project root is everything before /.gsd/projects/ (#2517) - const symlinkMarker = `${sep}.gsd${sep}projects${sep}`; - const symlinkIdx = basePath.indexOf(symlinkMarker); - if (symlinkIdx !== -1) { - const afterProjects = basePath.slice(symlinkIdx + symlinkMarker.length); - // Expect: /worktrees/... - const worktreeSeg = `${sep}worktrees${sep}`; - if (afterProjects.includes(worktreeSeg)) { - const projectRoot = basePath.slice(0, symlinkIdx); - return join(projectRoot, ".gsd", "gsd.db"); - } - } - - // Forward-slash variant for symlink-resolved layout - const fwdSymlinkMarker = "/.gsd/projects/"; - const fwdSymlinkIdx = basePath.indexOf(fwdSymlinkMarker); - if (fwdSymlinkIdx !== -1) { - const afterProjects = basePath.slice(fwdSymlinkIdx + fwdSymlinkMarker.length); - if (afterProjects.includes("/worktrees/")) { - const projectRoot = basePath.slice(0, fwdSymlinkIdx); - return join(projectRoot, ".gsd", "gsd.db"); - } - } - - - return join(basePath, ".gsd", "gsd.db"); -} - -export async function ensureDbOpen(basePath: string = process.cwd()): Promise { - try { - const db = await import("../gsd-db.js"); - const dbPath = resolveProjectRootDbPath(basePath); - const gsdDir = join(basePath, ".gsd"); - - // Derive the project root from the DB path (strip .gsd/gsd.db) - const projectRoot = join(dbPath, "..", ".."); - - // Open existing DB file (may be at project root for worktrees) - if (existsSync(dbPath)) { - const opened = db.openDatabase(dbPath); - if (opened) setLogBasePath(projectRoot); - return opened; - } - - // No DB file — create + migrate from Markdown if .gsd/ has content - if (existsSync(gsdDir)) { - const hasDecisions = existsSync(join(gsdDir, "DECISIONS.md")); - const hasRequirements = existsSync(join(gsdDir, "REQUIREMENTS.md")); - const hasMilestones = existsSync(join(gsdDir, "milestones")); - if (hasDecisions || hasRequirements || hasMilestones) { - const opened = db.openDatabase(dbPath); - if (opened) { - setLogBasePath(projectRoot); - try { - const { migrateFromMarkdown } = await import("../md-importer.js"); - migrateFromMarkdown(basePath); - } catch (err) { - logWarning("bootstrap", `ensureDbOpen auto-migration failed: ${(err as Error).message}`); - } - } - return opened; - } - - // .gsd/ exists but has no Markdown content (fresh project) — create empty DB - const opened = db.openDatabase(dbPath); - if (opened) setLogBasePath(projectRoot); - return opened; - } - - logWarning("bootstrap", "ensureDbOpen failed — no .gsd directory found"); - return false; - } catch (err) { - logWarning("bootstrap", `ensureDbOpen failed: ${(err as Error).message ?? String(err)}`); - return false; - } -} - -export function registerDynamicTools(pi: ExtensionAPI): void { - const baseBash = createBashTool(process.cwd(), { - spawnHook: (ctx) => ({ ...ctx, cwd: process.cwd() }), - }); - const dynamicBash = { - ...baseBash, - execute: async ( - toolCallId: string, - params: { command: string; timeout?: number }, - signal?: AbortSignal, - onUpdate?: unknown, - ctx?: unknown, - ) => { - const paramsWithTimeout = { - ...params, - timeout: params.timeout ?? DEFAULT_BASH_TIMEOUT_SECS, - }; - return (baseBash as any).execute(toolCallId, paramsWithTimeout, signal, onUpdate, ctx); - }, - }; - pi.registerTool(dynamicBash as any); - - const baseWrite = createWriteTool(process.cwd()); - pi.registerTool({ - ...baseWrite, - execute: async ( - toolCallId: string, - params: { path: string; content: string }, - signal?: AbortSignal, - onUpdate?: unknown, - ctx?: unknown, - ) => { - const fresh = createWriteTool(process.cwd()); - return (fresh as any).execute(toolCallId, params, signal, onUpdate, ctx); - }, - } as any); - - const baseRead = createReadTool(process.cwd()); - pi.registerTool({ - ...baseRead, - execute: async ( - toolCallId: string, - params: { path: string; offset?: number; limit?: number }, - signal?: AbortSignal, - onUpdate?: unknown, - ctx?: unknown, - ) => { - const fresh = createReadTool(process.cwd()); - return (fresh as any).execute(toolCallId, params, signal, onUpdate, ctx); - }, - } as any); - - const baseEdit = createEditTool(process.cwd()); - pi.registerTool({ - ...baseEdit, - execute: async ( - toolCallId: string, - params: { path: string; oldText: string; newText: string }, - signal?: AbortSignal, - onUpdate?: unknown, - ctx?: unknown, - ) => { - const fresh = createEditTool(process.cwd()); - return (fresh as any).execute(toolCallId, params, signal, onUpdate, ctx); - }, - } as any); -} diff --git a/src/resources/extensions/gsd/bootstrap/journal-tools.ts b/src/resources/extensions/gsd/bootstrap/journal-tools.ts deleted file mode 100644 index 9e0ae3402..000000000 --- a/src/resources/extensions/gsd/bootstrap/journal-tools.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { ExtensionAPI } from "@sf-run/pi-coding-agent"; - -import { queryJournal } from "../journal.js"; -import { logWarning } from "../workflow-logger.js"; - -export function registerJournalTools(pi: ExtensionAPI): void { - pi.registerTool({ - name: "gsd_journal_query", - label: "Query Journal", - description: - "Query the structured event journal for auto-mode iterations. " + - "Returns matching journal entries filtered by flow ID, unit ID, rule name, event type, or time range.", - promptSnippet: "Query the SF event journal with filters (flowId, unitId, rule, eventType, time range, limit)", - promptGuidelines: [ - "Filter by flowId to trace all events from a single auto-mode iteration.", - "Filter by unitId to reconstruct the causal chain for a specific milestone/slice/task.", - "Use limit to control context size — default is 100 entries.", - ], - parameters: Type.Object({ - flowId: Type.Optional(Type.String({ description: "Filter by flow ID (UUID grouping one iteration)" })), - unitId: Type.Optional(Type.String({ description: "Filter by unit ID (e.g. M001/S01/T01) from event data" })), - rule: Type.Optional(Type.String({ description: "Filter by rule name from the unified registry" })), - eventType: Type.Optional(Type.String({ description: "Filter by event type (e.g. dispatch-match, unit-start)" })), - after: Type.Optional(Type.String({ description: "ISO-8601 lower bound (inclusive)" })), - before: Type.Optional(Type.String({ description: "ISO-8601 upper bound (inclusive)" })), - limit: Type.Optional(Type.Number({ description: "Maximum entries to return (default: 100)", default: 100 })), - }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - try { - const filters: Record = {}; - if (params.flowId !== undefined) filters.flowId = params.flowId; - if (params.unitId !== undefined) filters.unitId = params.unitId; - if (params.rule !== undefined) filters.rule = params.rule; - if (params.eventType !== undefined) filters.eventType = params.eventType; - if (params.after !== undefined) filters.after = params.after; - if (params.before !== undefined) filters.before = params.before; - - const entries = queryJournal(process.cwd(), filters); - const limited = entries.slice(0, params.limit ?? 100); - - if (limited.length === 0) { - return { - content: [{ type: "text" as const, text: "No matching journal entries found." }], - details: { operation: "journal_query", count: 0 } as any, - }; - } - - return { - content: [{ type: "text" as const, text: JSON.stringify(limited, null, 2) }], - details: { operation: "journal_query", count: limited.length } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logWarning("tool", `gsd_journal_query tool failed: ${msg}`); - return { - content: [{ type: "text" as const, text: `Error querying journal: ${msg}` }], - details: { operation: "journal_query", error: msg } as any, - }; - } - }, - }); -} diff --git a/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts b/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts deleted file mode 100644 index 30df765c9..000000000 --- a/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts +++ /dev/null @@ -1,34 +0,0 @@ -// SF Extension — Notify Interceptor -// Wraps ctx.ui.notify() in-place to persist every notification through the -// notification store. Uses a WeakSet to prevent double-wrapping and handle -// UI context replacement on /reload gracefully. - -import type { ExtensionContext } from "@sf-run/pi-coding-agent"; - -import { appendNotification, type NotifySeverity } from "../notification-store.js"; - -// Track which ui context objects have been wrapped to prevent double-install. -// WeakSet allows GC to collect replaced uiContext instances after /reload. -const _wrappedContexts = new WeakSet(); - -/** - * Install the notify interceptor on a context's UI object. - * Mutates ctx.ui.notify in place — the original is called after persistence. - * Safe to call multiple times; no-ops if already installed on the same ui object. - */ -export function installNotifyInterceptor(ctx: ExtensionContext): void { - if (_wrappedContexts.has(ctx.ui)) return; - - const originalNotify = ctx.ui.notify.bind(ctx.ui); - - (ctx.ui as any).notify = (message: string, type?: "info" | "warning" | "error" | "success"): void => { - try { - appendNotification(message, (type ?? "info") as NotifySeverity, "notify"); - } catch { - // Non-fatal — never let persistence break the UI - } - originalNotify(message, type); - }; - - _wrappedContexts.add(ctx.ui); -} diff --git a/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts b/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts deleted file mode 100644 index 213554d5a..000000000 --- a/src/resources/extensions/gsd/bootstrap/provider-error-resume.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - ExtensionAPI, - ExtensionCommandContext, - ExtensionContext, -} from "@sf-run/pi-coding-agent"; - -import { getAutoDashboardData, startAuto, type AutoDashboardData } from "../auto.js"; -import { resetTransientRetryState } from "./agent-end-recovery.js"; - -type AutoResumeSnapshot = Pick; - -export interface ProviderErrorResumeDeps { - getSnapshot(): AutoResumeSnapshot; - startAuto( - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - base: string, - verboseMode: boolean, - options?: { step?: boolean }, - ): Promise; -} - -const defaultDeps: ProviderErrorResumeDeps = { - getSnapshot: () => getAutoDashboardData(), - startAuto, -}; - -export async function resumeAutoAfterProviderDelay( - pi: ExtensionAPI, - ctx: ExtensionContext, - deps: ProviderErrorResumeDeps = defaultDeps, -): Promise<"resumed" | "already-active" | "not-paused" | "missing-base"> { - const snapshot = deps.getSnapshot(); - - if (snapshot.active) return "already-active"; - if (!snapshot.paused) return "not-paused"; - - if (!snapshot.basePath) { - ctx.ui.notify( - "Provider error recovery delay elapsed, but no paused auto-mode base path was available. Leaving auto-mode paused.", - "warning", - ); - return "missing-base"; - } - - // Reset the transient retry counter before restarting — without this, - // consecutiveTransientCount accumulates across pause/resume cycles and - // permanently locks out auto-resume after MAX_TRANSIENT_AUTO_RESUMES errors. - resetTransientRetryState(); - - await deps.startAuto( - ctx as ExtensionCommandContext, - pi, - snapshot.basePath, - false, - { step: snapshot.stepMode }, - ); - return "resumed"; -} diff --git a/src/resources/extensions/gsd/bootstrap/query-tools.ts b/src/resources/extensions/gsd/bootstrap/query-tools.ts deleted file mode 100644 index bd6577ef7..000000000 --- a/src/resources/extensions/gsd/bootstrap/query-tools.ts +++ /dev/null @@ -1,34 +0,0 @@ -// GSD2 — Read-only query tools exposing DB state to the LLM via the WAL connection - -import { Type } from "@sinclair/typebox"; -import type { ExtensionAPI } from "@sf-run/pi-coding-agent"; -import { ensureDbOpen } from "./dynamic-tools.js"; -import { executeMilestoneStatus } from "../tools/workflow-tool-executors.js"; - -export function registerQueryTools(pi: ExtensionAPI): void { - pi.registerTool({ - name: "gsd_milestone_status", - label: "Milestone Status", - description: - "Read the current status of a milestone and all its slices from the SF database. " + - "Returns milestone metadata, per-slice status, and task counts per slice. " + - "Use this instead of querying .gsd/gsd.db directly via sqlite3 or better-sqlite3.", - promptSnippet: "Get milestone status, slice statuses, and task counts for a given milestoneId", - promptGuidelines: [ - "Use this tool — not sqlite3 or better-sqlite3 — to inspect milestone or slice state from the DB.", - ], - parameters: Type.Object({ - milestoneId: Type.String({ description: "Milestone ID to query (e.g. M001)" }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text", text: "Error: SF database is not available. Cannot read milestone status." }], - details: { operation: "milestone_status", error: "db_unavailable" }, - }; - } - return executeMilestoneStatus(params); - }, - }); -} diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts deleted file mode 100644 index a24c07bfc..000000000 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ /dev/null @@ -1,96 +0,0 @@ -// GSD2 — Extension registration: wires all SF tools, commands, and hooks into pi - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { registerExitCommand } from "../exit-command.js"; -import { registerWorktreeCommand } from "../worktree-command.js"; -import { registerDbTools } from "./db-tools.js"; -import { registerDynamicTools } from "./dynamic-tools.js"; -import { registerJournalTools } from "./journal-tools.js"; -import { registerQueryTools } from "./query-tools.js"; -import { registerHooks } from "./register-hooks.js"; -import { registerShortcuts } from "./register-shortcuts.js"; -import { writeCrashLog } from "./crash-log.js"; -import { logWarning } from "../workflow-logger.js"; - -export { writeCrashLog } from "./crash-log.js"; - -export function handleRecoverableExtensionProcessError(err: Error): boolean { - if ((err as NodeJS.ErrnoException).code === "EPIPE") { - process.exit(0); - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - const syscall = (err as NodeJS.ErrnoException).syscall; - if (syscall?.startsWith("spawn")) { - process.stderr.write(`[forge] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`); - return true; - } - if (syscall === "uv_cwd") { - process.stderr.write(`[forge] ENOENT (${syscall}): ${err.message}\n`); - return true; - } - } - return false; -} - -function installEpipeGuard(): void { - if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) { - const _gsdEpipeGuard = (err: Error): void => { - if (handleRecoverableExtensionProcessError(err)) return; - // Write crash log and exit cleanly for unrecoverable errors. - // Logging and continuing was the original double-fault fix (#3163), but - // continuing in an indeterminate state is worse than a clean exit (#3348). - writeCrashLog(err, "uncaughtException"); - process.exit(1); - }; - process.on("uncaughtException", _gsdEpipeGuard); - } - - if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) { - const _gsdRejectionGuard = (reason: unknown, _promise: Promise): void => { - const err = reason instanceof Error ? reason : new Error(String(reason)); - if (handleRecoverableExtensionProcessError(err)) return; - writeCrashLog(err, "unhandledRejection"); - process.exit(1); - }; - process.on("unhandledRejection", _gsdRejectionGuard); - } -} - -export function registerGsdExtension(pi: ExtensionAPI): void { - // Note: registerGSDCommand is called by index.ts before this function, - // so we intentionally skip it here to avoid double-registration. - registerWorktreeCommand(pi); - registerExitCommand(pi); - - installEpipeGuard(); - - pi.registerCommand("kill", { - description: "Exit SF immediately (no cleanup)", - handler: async (_args: string, _ctx: ExtensionCommandContext) => { - process.exit(0); - }, - }); - - // Wrap non-critical registrations individually so one failure - // doesn't prevent the others from loading. - const nonCriticalRegistrations: Array<[string, () => void]> = [ - ["dynamic-tools", () => registerDynamicTools(pi)], - ["db-tools", () => registerDbTools(pi)], - ["journal-tools", () => registerJournalTools(pi)], - ["query-tools", () => registerQueryTools(pi)], - ["shortcuts", () => registerShortcuts(pi)], - ["hooks", () => registerHooks(pi)], - ]; - - for (const [name, register] of nonCriticalRegistrations) { - try { - register(); - } catch (err) { - logWarning( - "bootstrap", - `Failed to register ${name}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } -} diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts deleted file mode 100644 index ed14f00f6..000000000 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { join } from "node:path"; - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; -import { isToolCallEventType } from "@sf-run/pi-coding-agent"; - -import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js"; -import { buildBeforeAgentStartResult } from "./system-context.js"; -import { handleAgentEnd } from "./agent-end-recovery.js"; -import { clearDiscussionFlowState, isDepthConfirmationAnswer, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution, isGateQuestionId, setPendingGate, clearPendingGate, getPendingGate, shouldBlockPendingGate, shouldBlockPendingGateBash, extractDepthVerificationMilestoneId } from "./write-gate.js"; -import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js"; -import { cleanupQuickBranch } from "../quick.js"; -import { getDiscussionMilestoneId } from "../guided-flow.js"; -import { loadToolApiKeys } from "../commands-config.js"; -import { loadFile, saveFile, formatContinue } from "../files.js"; -import { deriveState } from "../state.js"; -import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markToolStart, recordToolInvocationError } from "../auto.js"; -import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js"; -import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js"; -import { saveActivityLog } from "../activity-log.js"; -import { resetAskUserQuestionsCache } from "../../ask-user-questions.js"; -import { recordToolCall as safetyRecordToolCall, recordToolResult as safetyRecordToolResult } from "../safety/evidence-collector.js"; -import { recordToolCallName } from "../auto-tool-tracking.js"; -import { classifyCommand } from "../safety/destructive-guard.js"; -import { logWarning as safetyLogWarning } from "../workflow-logger.js"; -import { installNotifyInterceptor } from "./notify-interceptor.js"; -import { initNotificationStore } from "../notification-store.js"; -import { initNotificationWidget } from "../notification-widget.js"; -import { initHealthWidget } from "../health-widget.js"; -import { initializeLearningRuntime, resetLearningRuntime, selectLearnedModel } from "../learning/runtime.js"; - -// Skip the welcome screen on the very first session_start — cli.ts already -// printed it before the TUI launched. Only re-print on /clear (subsequent sessions). -let isFirstSession = true; - -async function syncServiceTierStatus(ctx: ExtensionContext): Promise { - const { getEffectiveServiceTier, formatServiceTierFooterStatus } = await import("../service-tier.js"); - ctx.ui.setStatus("gsd-fast", formatServiceTierFooterStatus(getEffectiveServiceTier(), ctx.model?.id)); -} - -export function registerHooks(pi: ExtensionAPI): void { - pi.on("session_start", async (_event, ctx) => { - resetLearningRuntime(); - try { - const sid = ctx.sessionManager?.getSessionId?.() ?? ""; - const sfile = ctx.sessionManager?.getSessionFile?.() ?? ""; - if (sid) { - process.stderr.write(`[forge] session ${sid.slice(0, 8)} · ${sfile}\n`); - } - } catch { - /* non-fatal */ - } - initNotificationStore(process.cwd()); - installNotifyInterceptor(ctx); - initNotificationWidget(ctx); - initHealthWidget(ctx); - resetWriteGateState(); - resetToolCallLoopGuard(); - resetAskUserQuestionsCache(); - await syncServiceTierStatus(ctx); - const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js"); - prepareWorkflowMcpForProject(ctx, process.cwd()); - await initializeLearningRuntime(); - - // Apply show_token_cost preference (#1515) - try { - const { loadEffectiveGSDPreferences } = await import("../preferences.js"); - const prefs = loadEffectiveGSDPreferences(); - process.env.SF_SHOW_TOKEN_COST = prefs?.preferences.show_token_cost ? "1" : ""; - } catch { /* non-fatal */ } - if (isFirstSession) { - isFirstSession = false; - } else { - try { - const gsdBinPath = process.env.SF_BIN_PATH; - if (gsdBinPath) { - const { dirname } = await import("node:path"); - const { printWelcomeScreen } = await import( - join(dirname(gsdBinPath), "welcome-screen.js") - ) as { printWelcomeScreen: (opts: { version: string; modelName?: string; provider?: string; remoteChannel?: string }) => void }; - - let remoteChannel: string | undefined; - try { - const { resolveRemoteConfig } = await import("../../remote-questions/config.js"); - const rc = resolveRemoteConfig(); - if (rc) remoteChannel = rc.channel; - } catch { /* non-fatal */ } - - printWelcomeScreen({ version: process.env.SF_VERSION || "0.0.0", remoteChannel }); - } - } catch { /* non-fatal */ } - } - loadToolApiKeys(); - }); - - pi.on("session_switch", async (_event, ctx) => { - resetLearningRuntime(); - initNotificationStore(process.cwd()); - installNotifyInterceptor(ctx); - resetWriteGateState(); - resetToolCallLoopGuard(); - resetAskUserQuestionsCache(); - clearDiscussionFlowState(); - await syncServiceTierStatus(ctx); - const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js"); - prepareWorkflowMcpForProject(ctx, process.cwd()); - await initializeLearningRuntime(); - loadToolApiKeys(); - }); - - pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { - return buildBeforeAgentStartResult(event, ctx); - }); - - pi.on("agent_end", async (event, ctx: ExtensionContext) => { - resetToolCallLoopGuard(); - resetAskUserQuestionsCache(); - await handleAgentEnd(pi, event, ctx); - }); - - // Squash-merge quick-task branch back to the original branch after the - // agent turn completes (#2668). cleanupQuickBranch is a no-op when no - // quick-return state is pending, so this is safe to call on every turn. - pi.on("turn_end", async () => { - try { - cleanupQuickBranch(); - } catch { - // Best-effort: don't break the turn lifecycle if cleanup fails. - } - }); - - pi.on("session_before_compact", async () => { - // Only cancel compaction while auto-mode is actively running. - // Paused auto-mode should allow compaction — the user may be doing - // interactive work (#3165). - if (isAutoActive()) { - return { cancel: true }; - } - const basePath = process.cwd(); - const { ensureDbOpen } = await import("./dynamic-tools.js"); - await ensureDbOpen(); - const state = await deriveState(basePath); - if (!state.activeMilestone || !state.activeSlice || !state.activeTask) return; - if (state.phase !== "executing") return; - - const sliceDir = resolveSlicePath(basePath, state.activeMilestone.id, state.activeSlice.id); - if (!sliceDir) return; - - const existingFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "CONTINUE"); - if (existingFile && await loadFile(existingFile)) return; - const legacyContinue = join(sliceDir, "continue.md"); - if (await loadFile(legacyContinue)) return; - - const continuePath = join(sliceDir, `${state.activeSlice.id}-CONTINUE.md`); - await saveFile(continuePath, formatContinue({ - frontmatter: { - milestone: state.activeMilestone.id, - slice: state.activeSlice.id, - task: state.activeTask.id, - step: 0, - totalSteps: 0, - status: "compacted" as const, - savedAt: new Date().toISOString(), - }, - completedWork: `Task ${state.activeTask.id} (${state.activeTask.title}) was in progress when compaction occurred.`, - remainingWork: "Check the task plan for remaining steps.", - decisions: "Check task summary files for prior decisions.", - context: "Session was auto-compacted by Pi. Resume with /gsd.", - nextAction: `Resume task ${state.activeTask.id}: ${state.activeTask.title}.`, - })); - }); - - pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => { - resetLearningRuntime(); - if (isParallelActive()) { - try { - await shutdownParallel(process.cwd()); - } catch { - // best-effort - } - } - if (!isAutoActive() && !isAutoPaused()) return; - const dash = getAutoDashboardData(); - if (dash.currentUnit) { - saveActivityLog(ctx, dash.basePath, dash.currentUnit.type, dash.currentUnit.id); - } - }); - - pi.on("tool_call", async (event) => { - const discussionBasePath = process.cwd(); - // ── Loop guard: block repeated identical tool calls ── - const loopCheck = checkToolCallLoop(event.toolName, event.input as Record); - if (loopCheck.block) { - return { block: true, reason: loopCheck.reason }; - } - - // ── Discussion gate enforcement: track pending gate questions ───────── - // Only gate-shaped ask_user_questions calls should block execution. - // The gate stays pending until the user selects the approval option. - if (event.toolName === "ask_user_questions") { - const questions: any[] = (event.input as any)?.questions ?? []; - const questionId = questions.find((question) => typeof question?.id === "string" && isGateQuestionId(question.id))?.id; - if (typeof questionId === "string") { - setPendingGate(questionId); - } - } - - // ── Discussion gate enforcement: block tool calls while gate is pending ── - // If ask_user_questions was called with a gate ID but hasn't been confirmed, - // block all non-read-only tool calls to prevent the model from skipping gates. - if (getPendingGate()) { - const milestoneId = getDiscussionMilestoneId(discussionBasePath); - if (isToolCallEventType("bash", event)) { - const bashGuard = shouldBlockPendingGateBash( - event.input.command, - milestoneId, - isQueuePhaseActive(), - ); - if (bashGuard.block) return bashGuard; - } else { - const gateGuard = shouldBlockPendingGate( - event.toolName, - milestoneId, - isQueuePhaseActive(), - ); - if (gateGuard.block) return gateGuard; - } - } - - // ── Queue-mode execution guard (#2545): block source-code mutations ── - // When /gsd queue is active, the agent should only create milestones, - // not execute work. Block write/edit to non-.gsd/ paths and bash commands - // that would modify files. - if (isQueuePhaseActive()) { - let queueInput = ""; - if (isToolCallEventType("write", event)) { - queueInput = event.input.path; - } else if (isToolCallEventType("edit", event)) { - queueInput = event.input.path; - } else if (isToolCallEventType("bash", event)) { - queueInput = event.input.command; - } - const queueGuard = shouldBlockQueueExecution(event.toolName, queueInput, true); - if (queueGuard.block) return queueGuard; - } - - // ── Single-writer engine: block direct writes to STATE.md ────────── - // Covers write, edit, and bash tools to prevent bypass vectors. - if (isToolCallEventType("write", event)) { - if (isBlockedStateFile(event.input.path)) { - return { block: true, reason: BLOCKED_WRITE_ERROR }; - } - } - - if (isToolCallEventType("edit", event)) { - if (isBlockedStateFile(event.input.path)) { - return { block: true, reason: BLOCKED_WRITE_ERROR }; - } - } - - if (isToolCallEventType("bash", event)) { - if (isBashWriteToStateFile(event.input.command)) { - return { block: true, reason: BLOCKED_WRITE_ERROR }; - } - } - - if (!isToolCallEventType("write", event)) return; - - const result = shouldBlockContextWrite( - event.toolName, - event.input.path, - getDiscussionMilestoneId(discussionBasePath), - isQueuePhaseActive(), - ); - if (result.block) return result; - }); - - // ── Safety harness: evidence collection + destructive command warnings ── - pi.on("tool_call", async (event, ctx) => { - if (!isAutoActive()) return; - safetyRecordToolCall(event.toolName, event.input as Record); - - // Destructive command classification (warn only, never block) - if (isToolCallEventType("bash", event)) { - const classification = classifyCommand(event.input.command); - if (classification.destructive) { - safetyLogWarning("safety", `destructive command: ${classification.labels.join(", ")}`, { - command: String(event.input.command).slice(0, 200), - }); - ctx.ui.notify( - `Destructive command detected: ${classification.labels.join(", ")}`, - "warning", - ); - } - } - }); - - pi.on("tool_result", async (event) => { - if (event.toolName !== "ask_user_questions") return; - const milestoneId = getDiscussionMilestoneId(process.cwd()); - const queueActive = isQueuePhaseActive(); - - 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. - 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(answer?.selected, 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. - const answer = details.response?.answers?.[question.id]; - const inferredMilestoneId = extractDepthVerificationMilestoneId(question.id) ?? milestoneId; - if (isDepthConfirmationAnswer(answer?.selected, question.options)) { - markDepthVerified(inferredMilestoneId); - clearPendingGate(); - } - break; - } - } - - if (!milestoneId && !queueActive) return; - if (!milestoneId) return; - - const basePath = process.cwd(); - const milestoneDir = resolveMilestonePath(basePath, milestoneId); - if (!milestoneDir) return; - - const discussionPath = join(milestoneDir, buildMilestoneFileName(milestoneId, "DISCUSSION")); - const timestamp = new Date().toISOString(); - const lines: string[] = [`## Exchange — ${timestamp}`, ""]; - for (const question of questions) { - lines.push(`### ${question.header ?? "Question"}`, "", question.question ?? ""); - if (Array.isArray(question.options)) { - lines.push(""); - for (const opt of question.options) { - lines.push(`- **${opt.label}** — ${opt.description ?? ""}`); - } - } - const answer = details.response?.answers?.[question.id]; - if (answer) { - lines.push(""); - const selected = Array.isArray(answer.selected) ? answer.selected.join(", ") : answer.selected; - lines.push(`**Selected:** ${selected}`); - if (answer.notes) { - lines.push(`**Notes:** ${answer.notes}`); - } - } - lines.push(""); - } - lines.push("---", ""); - const existing = await loadFile(discussionPath) ?? `# ${milestoneId} Discussion Log\n\n`; - await saveFile(discussionPath, existing + lines.join("\n")); - }); - - pi.on("tool_execution_start", async (event) => { - if (!isAutoActive()) return; - markToolStart(event.toolCallId); - recordToolCallName(event.toolName); - }); - - pi.on("tool_execution_end", async (event) => { - markToolEnd(event.toolCallId); - // #2883: Capture tool invocation errors (malformed/truncated JSON arguments) - // so postUnitPreVerification can break the retry loop instead of re-dispatching. - if (event.isError && event.toolName.startsWith("gsd_")) { - const errorText = typeof event.result === "string" - ? event.result - : (typeof event.result?.content?.[0]?.text === "string" ? event.result.content[0].text : String(event.result)); - recordToolInvocationError(event.toolName, errorText); - } - // Safety harness: record tool execution results for evidence cross-referencing - if (isAutoActive()) { - safetyRecordToolResult(event.toolCallId, event.toolName, event.result, event.isError); - } - }); - - pi.on("model_select", async (_event, ctx) => { - await syncServiceTierStatus(ctx); - }); - - pi.on("before_provider_request", async (event) => { - const payload = event.payload as Record | null; - if (!payload || typeof payload !== "object") return; - - // ── Observation Masking ───────────────────────────────────────────── - // Replace old tool results with placeholders to reduce context bloat. - // Only active during auto-mode when context_management.observation_masking is enabled. - if (isAutoActive()) { - try { - const { loadEffectiveGSDPreferences } = await import("../preferences.js"); - const prefs = loadEffectiveGSDPreferences(); - const cmConfig = prefs?.preferences.context_management; - - // Observation masking: replace old tool results with placeholders - if (cmConfig?.observation_masking !== false) { - const keepTurns = cmConfig?.observation_mask_turns ?? 8; - const { createObservationMask } = await import("../context-masker.js"); - const mask = createObservationMask(keepTurns); - const messages = payload.messages; - if (Array.isArray(messages)) { - payload.messages = mask(messages); - } - } - - // Tool result truncation: cap individual tool result content length. - // In pi-ai format, toolResult messages have role: "toolResult" and content: TextContent[]. - // Creates new objects to avoid mutating shared conversation state. - const maxChars = cmConfig?.tool_result_max_chars ?? 800; - const msgs = payload.messages; - if (Array.isArray(msgs)) { - payload.messages = msgs.map((msg: Record) => { - // Match toolResult messages (role: "toolResult", content is array of content blocks) - if (msg?.role === "toolResult" && Array.isArray(msg.content)) { - const blocks = msg.content as Array>; - const totalLen = blocks.reduce((sum: number, b) => sum + (typeof b.text === "string" ? b.text.length : 0), 0); - if (totalLen > maxChars) { - const truncated = blocks.map(b => { - if (typeof b.text === "string" && b.text.length > maxChars) { - return { ...b, text: b.text.slice(0, maxChars) + "\n…[truncated]" }; - } - return b; - }); - return { ...msg, content: truncated }; - } - } - return msg; - }); - } - } catch { /* non-fatal */ } - } - - // ── Service Tier ──────────────────────────────────────────────────── - const modelId = event.model?.id; - if (!modelId) return payload; - const { getEffectiveServiceTier, supportsServiceTier } = await import("../service-tier.js"); - const tier = getEffectiveServiceTier(); - if (!tier || !supportsServiceTier(modelId)) return payload; - payload.service_tier = tier; - return payload; - }); - - // Capability-aware model routing hook (ADR-004) - // Extensions can override model selection by returning { modelId: "..." } - // Return undefined to let the built-in capability scoring proceed. - pi.on("before_model_select", async (event) => { - return selectLearnedModel({ - unitType: event.unitType, - eligibleModels: event.eligibleModels, - phaseConfig: event.phaseConfig, - }); - }); - - // Tool set adaptation hook (ADR-005 Phase 4) - // Extensions can override tool set after model selection by returning { toolNames: [...] } - // Return undefined to let the built-in provider compatibility filtering proceed. - pi.on("adjust_tool_set", async (_event) => { - // Default: no override — let provider capability filtering handle tool set - return undefined; - }); -} diff --git a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts deleted file mode 100644 index bf7c8cd82..000000000 --- a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -import type { ExtensionAPI, ExtensionContext } from "@sf-run/pi-coding-agent"; -import { Key } from "@sf-run/pi-tui"; - -import { GSDDashboardOverlay } from "../dashboard-overlay.js"; -import { GSDNotificationOverlay } from "../notification-overlay.js"; -import { ParallelMonitorOverlay } from "../parallel-monitor-overlay.js"; -import { SF_SHORTCUTS } from "../shortcut-defs.js"; -import { projectRoot } from "../commands/context.js"; -import { shortcutDesc } from "../../shared/mod.js"; - -export function registerShortcuts(pi: ExtensionAPI): void { - const overlayOptions = { - width: "90%", - minWidth: 80, - maxHeight: "92%", - anchor: "center", - } as const; - - const openDashboardOverlay = async (ctx: ExtensionContext) => { - const basePath = projectRoot(); - if (!existsSync(join(basePath, ".gsd"))) { - ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info"); - return; - } - await ctx.ui.custom( - (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions, - }, - ); - }; - - const openNotificationsOverlay = async (ctx: ExtensionContext) => { - await ctx.ui.custom( - (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions: { - width: "80%", - minWidth: 60, - maxHeight: "88%", - anchor: "center", - backdrop: true, - }, - }, - ); - }; - - const openParallelOverlay = async (ctx: ExtensionContext) => { - const basePath = projectRoot(); - const parallelDir = join(basePath, ".gsd", "parallel"); - if (!existsSync(parallelDir)) { - ctx.ui.notify("No parallel workers found. Run /gsd parallel start first.", "info"); - return; - } - await ctx.ui.custom( - (tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(true), basePath), - { - overlay: true, - overlayOptions, - }, - ); - }; - - pi.registerShortcut(Key.ctrlAlt(SF_SHORTCUTS.dashboard.key), { - description: shortcutDesc(SF_SHORTCUTS.dashboard.action, SF_SHORTCUTS.dashboard.command), - handler: openDashboardOverlay, - }); - - // Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably. - pi.registerShortcut(Key.ctrlShift(SF_SHORTCUTS.dashboard.key), { - description: shortcutDesc(`${SF_SHORTCUTS.dashboard.action} (fallback)`, SF_SHORTCUTS.dashboard.command), - handler: openDashboardOverlay, - }); - - pi.registerShortcut(Key.ctrlAlt(SF_SHORTCUTS.notifications.key), { - description: shortcutDesc(SF_SHORTCUTS.notifications.action, SF_SHORTCUTS.notifications.command), - handler: openNotificationsOverlay, - }); - - // Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably. - pi.registerShortcut(Key.ctrlShift(SF_SHORTCUTS.notifications.key), { - description: shortcutDesc(`${SF_SHORTCUTS.notifications.action} (fallback)`, SF_SHORTCUTS.notifications.command), - handler: openNotificationsOverlay, - }); - - pi.registerShortcut(Key.ctrlAlt(SF_SHORTCUTS.parallel.key), { - description: shortcutDesc(SF_SHORTCUTS.parallel.action, SF_SHORTCUTS.parallel.command), - handler: openParallelOverlay, - }); - - // No Ctrl+Shift+P fallback — conflicts with cycleModelBackward (shift+ctrl+p). - // Use Ctrl+Alt+P or /gsd parallel watch instead. -} diff --git a/src/resources/extensions/gsd/bootstrap/sanitize-complete-milestone.ts b/src/resources/extensions/gsd/bootstrap/sanitize-complete-milestone.ts deleted file mode 100644 index d2fc56f43..000000000 --- a/src/resources/extensions/gsd/bootstrap/sanitize-complete-milestone.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Input sanitization for gsd_complete_milestone parameters. - * - * The Claude SDK deserializes tool-call JSON before the handler runs. - * When an LLM (especially smaller models like haiku) generates large markdown - * parameters, the JSON can arrive with subtly wrong types — numbers where - * strings are expected, null where arrays belong, string "true" instead of - * boolean true, etc. This sanitizer normalizes all fields so - * handleCompleteMilestone never crashes on type mismatches. - * - * See: https://github.com/singularity-forge/sf-run/issues/3013 - */ - -import type { CompleteMilestoneParams } from "../tools/complete-milestone.js"; - -/** - * Coerce an unknown value to a trimmed string. - * Returns "" for null / undefined. - */ -function toStr(v: unknown): string { - if (v == null) return ""; - return String(v).trim(); -} - -/** - * Coerce an unknown value to an array of trimmed, non-empty strings. - * - If already an array, filter/trim each element. - * - Otherwise return []. - */ -function toStrArray(v: unknown): string[] { - if (!Array.isArray(v)) return []; - return v - .map((item) => (item == null ? "" : String(item).trim())) - .filter((s) => s.length > 0); -} - -/** - * Sanitize raw params from the tool-call framework into well-typed - * CompleteMilestoneParams, tolerating type mismatches from LLM JSON quirks. - */ -export function sanitizeCompleteMilestoneParams(raw: Record): CompleteMilestoneParams { - return { - milestoneId: toStr(raw.milestoneId), - title: toStr(raw.title), - oneLiner: toStr(raw.oneLiner), - narrative: toStr(raw.narrative), - successCriteriaResults: toStr(raw.successCriteriaResults), - definitionOfDoneResults: toStr(raw.definitionOfDoneResults), - requirementOutcomes: toStr(raw.requirementOutcomes), - keyDecisions: toStrArray(raw.keyDecisions), - keyFiles: toStrArray(raw.keyFiles), - lessonsLearned: toStrArray(raw.lessonsLearned), - followUps: toStr(raw.followUps), - deviations: toStr(raw.deviations), - verificationPassed: raw.verificationPassed === true || raw.verificationPassed === "true", - }; -} diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts deleted file mode 100644 index 50ce25c83..000000000 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { existsSync, readFileSync, unlinkSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -import type { ExtensionContext } from "@sf-run/pi-coding-agent"; - -import { logWarning } from "../workflow-logger.js"; -import { debugTime } from "../debug-logger.js"; -import { loadPrompt, getTemplatesDir } from "../prompt-loader.js"; -import { readForensicsMarker } from "../forensics.js"; -import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js"; -import { resolveModelWithFallbacksForUnit } from "../preferences-models.js"; -import { resolveSkillReference } from "../preferences-skills.js"; -import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js"; -import { ensureCodebaseMapFresh, readCodebaseMap } from "../codebase-generator.js"; -import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-discovery.js"; -import { getActiveAutoWorktreeContext } from "../auto-worktree.js"; -import { getActiveWorktreeName, getWorktreeOriginalCwd } from "../worktree-command.js"; -import { deriveState } from "../state.js"; -import { formatOverridesSection, formatShortcut, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js"; -import { toPosixPath } from "../../shared/mod.js"; -import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../../cmux/index.js"; -import { autoEnableCmuxPreferences } from "../commands-cmux.js"; - -const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); - -/** - * Bundled skill triggers — resolved dynamically at runtime instead of - * hardcoding absolute paths in the system prompt template. Only skills - * that actually exist on disk are included in the table. (#3575) - */ -const BUNDLED_SKILL_TRIGGERS: Array<{ trigger: string; skill: string }> = [ - { trigger: "Frontend UI - web components, pages, landing pages, dashboards, React/HTML/CSS, styling", skill: "frontend-design" }, - { trigger: "macOS or iOS apps - SwiftUI, Xcode, App Store", skill: "swiftui" }, - { trigger: "Debugging - complex bugs, failing tests, root-cause investigation after standard approaches fail", skill: "debug-like-expert" }, -]; - -function buildBundledSkillsTable(): string { - const cwd = process.cwd(); - const rows: string[] = []; - for (const { trigger, skill } of BUNDLED_SKILL_TRIGGERS) { - const resolution = resolveSkillReference(skill, cwd); - if (resolution.method === "unresolved") continue; // skill not installed — omit from prompt - rows.push(`| ${trigger} | \`${resolution.resolvedPath}\` |`); - } - if (rows.length === 0) { - return "*No bundled skills found. Install skills to `~/.agents/skills/` or `~/.claude/skills/`.*"; - } - return `| Trigger | Skill to load |\n|---|---|\n${rows.join("\n")}`; -} - -function warnDeprecatedAgentInstructions(): void { - const paths = [ - join(gsdHome, "agent-instructions.md"), - join(process.cwd(), ".gsd", "agent-instructions.md"), - ]; - for (const path of paths) { - if (existsSync(path)) { - console.warn( - `[SF] DEPRECATED: ${path} is no longer loaded. ` + - `Migrate your instructions to AGENTS.md (or CLAUDE.md) in the same directory. ` + - `See https://github.com/gsd-build/SF/issues/1492`, - ); - } - } -} - -export async function buildBeforeAgentStartResult( - event: { prompt: string; systemPrompt: string }, - ctx: ExtensionContext, -): Promise<{ systemPrompt: string; message?: { customType: string; content: string; display: false } } | undefined> { - if (!existsSync(join(process.cwd(), ".gsd"))) return undefined; - - const stopContextTimer = debugTime("context-inject"); - const systemContent = loadPrompt("system", { - bundledSkillsTable: buildBundledSkillsTable(), - templatesDir: getTemplatesDir(), - shortcutDashboard: formatShortcut("Ctrl+Alt+G"), - shortcutShell: formatShortcut("Ctrl+Alt+B"), - }); - let loadedPreferences = loadEffectiveGSDPreferences(); - if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) { - markCmuxPromptShown(); - if (autoEnableCmuxPreferences()) { - loadedPreferences = loadEffectiveGSDPreferences(); - ctx.ui.notify( - "cmux detected — auto-enabled. Run /gsd cmux off to disable.", - "info", - ); - } - } - - let preferenceBlock = ""; - if (loadedPreferences) { - const cwd = process.cwd(); - const report = resolveAllSkillReferences(loadedPreferences.preferences, cwd); - preferenceBlock = `\n\n${renderPreferencesForSystemPrompt(loadedPreferences.preferences, report.resolutions)}`; - if (report.warnings.length > 0) { - ctx.ui.notify( - `SF skill preferences: ${report.warnings.length} unresolved skill${report.warnings.length === 1 ? "" : "s"}: ${report.warnings.join(", ")}`, - "warning", - ); - } - } - - const { block: knowledgeBlock, globalSizeKb } = loadKnowledgeBlock(gsdHome, process.cwd()); - if (globalSizeKb > 4) { - ctx.ui.notify( - `SF: ~/.gsd/agent/KNOWLEDGE.md is ${globalSizeKb.toFixed(1)}KB — consider trimming to keep system prompt lean.`, - "warning", - ); - } - - let memoryBlock = ""; - try { - const { formatMemoriesForPrompt, getActiveMemoriesRanked } = await import("../memory-store.js"); - const memories = getActiveMemoriesRanked(30); - if (memories.length > 0) { - const formatted = formatMemoriesForPrompt(memories, 2000); - if (formatted) { - memoryBlock = `\n\n${formatted}`; - } - } - } catch (e) { - logWarning("bootstrap", `memory block fetch failed: ${(e as Error).message}`); - } - - let newSkillsBlock = ""; - if (hasSkillSnapshot()) { - const newSkills = detectNewSkills(); - if (newSkills.length > 0) { - newSkillsBlock = formatSkillsXml(newSkills); - } - } - - let codebaseBlock = ""; - try { - const codebaseOptions = loadedPreferences?.preferences?.codebase - ? { - excludePatterns: loadedPreferences.preferences.codebase.exclude_patterns, - maxFiles: loadedPreferences.preferences.codebase.max_files, - collapseThreshold: loadedPreferences.preferences.codebase.collapse_threshold, - } - : undefined; - ensureCodebaseMapFresh(process.cwd(), codebaseOptions); - } catch (e) { - logWarning("bootstrap", `CODEBASE refresh failed: ${(e as Error).message}`); - } - - const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE"); - const rawCodebase = readCodebaseMap(process.cwd()); - if (existsSync(codebasePath) && rawCodebase) { - try { - const rawContent = rawCodebase.trim(); - if (rawContent) { - // Cap injection size to ~2 000 tokens to avoid bloating every request. - // Full map is always available at .gsd/CODEBASE.md. - const MAX_CODEBASE_CHARS = 8_000; - const generatedMatch = rawContent.match(/Generated: (\S+)/); - const generatedAt = generatedMatch?.[1] ?? "unknown"; - const content = rawContent.length > MAX_CODEBASE_CHARS - ? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*" - : rawContent; - codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, auto-refreshed when SF detects tracked file changes; use /gsd codebase stats for status)]\n\n${content}`; - } - } catch (e) { - logWarning("bootstrap", `CODEBASE file read failed: ${(e as Error).message}`); - } - } - - warnDeprecatedAgentInstructions(); - - const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); - - // Re-inject forensics context on follow-up turns (#2941) - const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd(), event.prompt) : null; - - const worktreeBlock = buildWorktreeContextBlock(); - - const subagentModelConfig = resolveModelWithFallbacksForUnit("subagent"); - const subagentModelBlock = subagentModelConfig - ? `\n\n## Subagent Model\n\nWhen spawning subagents via the \`subagent\` tool, always pass \`model: "${subagentModelConfig.primary}"\` in the tool call parameters. Never omit this — always specify it explicitly.` - : ""; - - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — SF]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${subagentModelBlock}`; - - stopContextTimer({ - systemPromptSize: fullSystem.length, - injectionSize: injection?.length ?? forensicsInjection?.length ?? 0, - hasPreferences: preferenceBlock.length > 0, - hasNewSkills: newSkillsBlock.length > 0, - }); - - // Determine which context message to inject (guided execute takes priority) - const contextMessage = injection - ? { customType: "gsd-guided-context", content: injection, display: false as const } - : forensicsInjection - ? { customType: "gsd-forensics", content: forensicsInjection, display: false as const } - : null; - - return { - systemPrompt: fullSystem, - ...(contextMessage ? { message: contextMessage } : {}), - }; -} - -export function loadKnowledgeBlock(gsdHomeDir: string, cwd: string): { block: string; globalSizeKb: number } { - // 1. Global knowledge (~/.gsd/agent/KNOWLEDGE.md) — cross-project, user-maintained - let globalKnowledge = ""; - let globalSizeKb = 0; - const globalKnowledgePath = join(gsdHomeDir, "agent", "KNOWLEDGE.md"); - if (existsSync(globalKnowledgePath)) { - try { - const content = readFileSync(globalKnowledgePath, "utf-8").trim(); - if (content) { - globalSizeKb = Buffer.byteLength(content, "utf-8") / 1024; - globalKnowledge = content; - } - } catch (e) { - logWarning("bootstrap", `global knowledge file read failed: ${(e as Error).message}`); - } - } - - // 2. Project knowledge (.gsd/KNOWLEDGE.md) — project-specific - let projectKnowledge = ""; - const knowledgePath = resolveGsdRootFile(cwd, "KNOWLEDGE"); - if (existsSync(knowledgePath)) { - try { - const content = readFileSync(knowledgePath, "utf-8").trim(); - if (content) projectKnowledge = content; - } catch (e) { - logWarning("bootstrap", `project knowledge file read failed: ${(e as Error).message}`); - } - } - - if (!globalKnowledge && !projectKnowledge) { - return { block: "", globalSizeKb: 0 }; - } - - const parts: string[] = []; - if (globalKnowledge) parts.push(`## Global Knowledge\n\n${globalKnowledge}`); - if (projectKnowledge) parts.push(`## Project Knowledge\n\n${projectKnowledge}`); - return { - block: `\n\n[KNOWLEDGE — Rules, patterns, and lessons learned]\n\n${parts.join("\n\n")}`, - globalSizeKb, - }; -} - -function buildWorktreeContextBlock(): string { - const worktreeName = getActiveWorktreeName(); - const worktreeMainCwd = getWorktreeOriginalCwd(); - const autoWorktree = getActiveAutoWorktreeContext(); - - if (worktreeName && worktreeMainCwd) { - return [ - "", - "", - "[WORKTREE CONTEXT — OVERRIDES CURRENT WORKING DIRECTORY ABOVE]", - `IMPORTANT: Ignore the "Current working directory" shown earlier in this prompt.`, - `The actual current working directory is: ${toPosixPath(process.cwd())}`, - "", - `You are working inside a SF worktree.`, - `- Worktree name: ${worktreeName}`, - `- Worktree path (this is the real cwd): ${toPosixPath(process.cwd())}`, - `- Main project: ${toPosixPath(worktreeMainCwd)}`, - `- Branch: worktree/${worktreeName}`, - "", - "All file operations, bash commands, and SF state resolve against the worktree path above.", - "Use /worktree merge to merge changes back. Use /worktree return to switch back to the main tree.", - ].join("\n"); - } - - if (autoWorktree) { - return [ - "", - "", - "[WORKTREE CONTEXT — OVERRIDES CURRENT WORKING DIRECTORY ABOVE]", - `IMPORTANT: Ignore the "Current working directory" shown earlier in this prompt.`, - `The actual current working directory is: ${toPosixPath(process.cwd())}`, - "", - "You are working inside a SF auto-worktree.", - `- Milestone worktree: ${autoWorktree.worktreeName}`, - `- Worktree path (this is the real cwd): ${toPosixPath(process.cwd())}`, - `- Main project: ${toPosixPath(autoWorktree.originalBase)}`, - `- Branch: ${autoWorktree.branch}`, - "", - "All file operations, bash commands, and SF state resolve against the worktree path above.", - "Write every .gsd artifact in the worktree path above, never in the main project tree.", - ].join("\n"); - } - - return ""; -} - -/** - * Low-entropy resume intent patterns — short phrases a user types to - * continue work after a pause, rate limit, or context reset (#3615). - * Tested against the trimmed, lowercased prompt with trailing punctuation stripped. - */ -const RESUME_INTENT_PATTERNS = /^(continue|resume|ok|go|go ahead|proceed|keep going|carry on|next|yes|yeah|yep|sure|do it|let's go|pick up where you left off)$/; - -async function buildGuidedExecuteContextInjection(prompt: string, basePath: string): Promise { - const ensureStateDbOpen = async () => { - const { ensureDbOpen } = await import("./dynamic-tools.js"); - await ensureDbOpen(); - }; - - const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); - if (executeMatch) { - const [, taskId, taskTitle, sliceId, milestoneId] = executeMatch; - return buildTaskExecutionContextInjection(basePath, milestoneId, sliceId, taskId, taskTitle); - } - - const resumeMatch = prompt.match(/Resume interrupted work\.[\s\S]*?slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i); - if (resumeMatch) { - const [, sliceId, milestoneId] = resumeMatch; - await ensureStateDbOpen(); - const state = await deriveState(basePath); - if (state.activeMilestone?.id === milestoneId && state.activeSlice?.id === sliceId && state.activeTask) { - return buildTaskExecutionContextInjection(basePath, milestoneId, sliceId, state.activeTask.id, state.activeTask.title); - } - } - - // Fallback: low-entropy resume prompt (e.g., "continue", "ok", "go ahead") - // during an active executing task — inject task context so the agent - // doesn't rebuild from scratch (#3615). - // Intent-gated: only fire for short, resume-like prompts to avoid hijacking - // control/help/diagnostic prompts with unrelated execution context. - // Phase-gated: only fire during "executing" to avoid misrouting during - // replanning, gate evaluation, or other non-execution phases. - const trimmed = prompt.trim().toLowerCase().replace(/[.!?,]+$/g, ""); - if (RESUME_INTENT_PATTERNS.test(trimmed)) { - await ensureStateDbOpen(); - const state = await deriveState(basePath); - if (state.phase === "executing" && state.activeTask && state.activeMilestone && state.activeSlice) { - return buildTaskExecutionContextInjection( - basePath, - state.activeMilestone.id, - state.activeSlice.id, - state.activeTask.id, - state.activeTask.title, - ); - } - } - - return null; -} - -async function buildTaskExecutionContextInjection( - basePath: string, - milestoneId: string, - sliceId: string, - taskId: string, - taskTitle: string, -): Promise { - const taskPlanPath = resolveTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); - const taskPlanRelPath = relTaskFile(basePath, milestoneId, sliceId, taskId, "PLAN"); - const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null; - const taskPlanInline = taskPlanContent - ? ["## Inlined Task Plan (authoritative local execution contract)", `Source: \`${taskPlanRelPath}\``, "", taskPlanContent.trim()].join("\n") - : ["## Inlined Task Plan (authoritative local execution contract)", `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`].join("\n"); - - const slicePlanPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN"); - const slicePlanRelPath = relSliceFile(basePath, milestoneId, sliceId, "PLAN"); - const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null; - const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, slicePlanRelPath); - const priorTaskLines = await buildCarryForwardLines(basePath, milestoneId, sliceId, taskId); - const resumeSection = await buildResumeSection(basePath, milestoneId, sliceId); - const activeOverrides = await loadActiveOverrides(basePath); - const overridesSection = formatOverridesSection(activeOverrides); - - return [ - "[SF Guided Execute Context]", - "Use this injected context as startup context for guided task execution. Treat the inlined task plan as the authoritative local execution contract. Use source artifacts to verify details and run checks.", - overridesSection, "", - "", - resumeSection, - "", - "## Carry-Forward Context", - ...priorTaskLines, - "", - taskPlanInline, - "", - slicePlanExcerpt, - "", - "## Backing Source Artifacts", - `- Slice plan: \`${slicePlanRelPath}\``, - `- Task plan source: \`${taskPlanRelPath}\``, - ].join("\n"); -} - -async function buildCarryForwardLines( - basePath: string, - milestoneId: string, - sliceId: string, - taskId: string, -): Promise { - const tasksDir = resolveTasksDir(basePath, milestoneId, sliceId); - if (!tasksDir) return ["- No prior task summaries in this slice."]; - - const currentNum = parseInt(taskId.replace(/^T/, ""), 10); - const sliceRel = relSlicePath(basePath, milestoneId, sliceId); - const summaryFiles = resolveTaskFiles(tasksDir, "SUMMARY") - .filter((file) => parseInt(file.replace(/^T/, ""), 10) < currentNum) - .sort(); - - if (summaryFiles.length === 0) return ["- No prior task summaries in this slice."]; - - return Promise.all(summaryFiles.map(async (file) => { - const absPath = join(tasksDir, file); - const content = await loadFile(absPath); - const relPath = `${sliceRel}/tasks/${file}`; - if (!content) return `- \`${relPath}\``; - - const summary = parseSummary(content); - const provided = summary.frontmatter.provides.slice(0, 2).join("; "); - const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; "); - const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; "); - const diagnostics = extractMarkdownSection(content, "Diagnostics"); - const parts = [summary.title || relPath]; - if (summary.oneLiner) parts.push(summary.oneLiner); - if (provided) parts.push(`provides: ${provided}`); - if (decisions) parts.push(`decisions: ${decisions}`); - if (patterns) parts.push(`patterns: ${patterns}`); - if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); - return `- \`${relPath}\` — ${parts.join(" | ")}`; - })); -} - -async function buildResumeSection(basePath: string, milestoneId: string, sliceId: string): Promise { - const continueFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE"); - const legacyDir = resolveSlicePath(basePath, milestoneId, sliceId); - const legacyPath = legacyDir ? join(legacyDir, "continue.md") : null; - const continueContent = continueFile ? await loadFile(continueFile) : null; - const legacyContent = !continueContent && legacyPath ? await loadFile(legacyPath) : null; - const resolvedContent = continueContent ?? legacyContent; - const resolvedRelPath = continueContent - ? relSliceFile(basePath, milestoneId, sliceId, "CONTINUE") - : (legacyPath ? `${relSlicePath(basePath, milestoneId, sliceId)}/continue.md` : null); - - if (!resolvedContent || !resolvedRelPath) { - return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n"); - } - - const cont = parseContinue(resolvedContent); - const lines = [ - "## Resume State", - `Source: \`${resolvedRelPath}\``, - `- Status: ${cont.frontmatter.status || "in_progress"}`, - ]; - if (cont.frontmatter.step && cont.frontmatter.totalSteps) { - lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`); - } - if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`); - if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); - if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); - if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); - return lines.join("\n"); -} - -function extractSliceExecutionExcerpt(content: string | null, relPath: string): string { - if (!content) { - return ["## Slice Plan Excerpt", `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`].join("\n"); - } - const lines = content.split("\n"); - const goalLine = lines.find((line) => line.startsWith("**Goal:**"))?.trim(); - const demoLine = lines.find((line) => line.startsWith("**Demo:**"))?.trim(); - const verification = extractMarkdownSection(content, "Verification"); - const observability = extractMarkdownSection(content, "Observability / Diagnostics"); - const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; - if (goalLine) parts.push(goalLine); - if (demoLine) parts.push(demoLine); - if (verification) parts.push("", "### Slice Verification", verification.trim()); - if (observability) parts.push("", "### Slice Observability / Diagnostics", observability.trim()); - return parts.join("\n"); -} - -function extractMarkdownSection(content: string, heading: string): string | null { - const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content); - if (!match) return null; - const start = match.index + match[0].length; - const rest = content.slice(start); - const nextHeading = rest.match(/^##\s+/m); - const end = nextHeading?.index ?? rest.length; - return rest.slice(0, end).trim(); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function oneLine(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -// ─── Forensics Context Re-injection (#2941) ────────────────────────────────── - -/** - * Check for an active forensics session and return the prompt content - * so it can be re-injected on follow-up turns. - */ -export function buildForensicsContextInjection(basePath: string, prompt: string): string | null { - const marker = readForensicsMarker(basePath); - if (!marker) return null; - - // Expire markers older than 2 hours to avoid stale context - const age = Date.now() - new Date(marker.createdAt).getTime(); - if (age > 2 * 60 * 60 * 1000) { - clearForensicsMarker(basePath); - return null; - } - - const trimmed = prompt.trim().toLowerCase().replace(/[.!?,]+$/g, ""); - if (trimmed && !RESUME_INTENT_PATTERNS.test(trimmed)) { - clearForensicsMarker(basePath); - return null; - } - - return marker.promptContent; -} - -/** - * Remove the active forensics marker file, e.g. when the investigation - * is complete or the session expires. - */ -export function clearForensicsMarker(basePath: string): void { - const markerPath = join(basePath, ".gsd", "runtime", "active-forensics.json"); - if (existsSync(markerPath)) { - try { - unlinkSync(markerPath); - } catch (e) { - logWarning("bootstrap", `unlinkSync forensics marker failed: ${(e as Error).message}`); - } - } -} diff --git a/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts b/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts deleted file mode 100644 index 4d325fbf1..000000000 --- a/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Tool-call loop guard. - * - * Detects when a model calls the same tool with identical arguments - * repeatedly within a single agent turn. Works in both auto-mode and - * interactive sessions by hooking into the `tool_call` event, which - * fires before execution and can block the call. - * - * The guard uses a sliding window: it tracks the last N tool signatures - * and blocks when the same signature appears more than MAX_CONSECUTIVE - * times in a row. Resets on each agent turn (session_start, agent_end) - * and when a different tool call breaks the streak. - */ - -import { createHash } from "node:crypto"; - -const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4; - -/** Interactive/user-facing tools where even 1 duplicate is confusing. */ -const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]); -const MAX_CONSECUTIVE_STRICT = 1; - -let consecutiveCount = 0; -let lastSignature = ""; -let lastToolName = ""; -let enabled = true; - -/** Hash tool name + args into a compact signature for comparison. */ -function hashToolCall(toolName: string, args: Record): string { - const h = createHash("sha256"); - h.update(toolName); - // Sort keys recursively for deterministic hashing regardless of object key order - h.update(JSON.stringify(args, (_key, value) => - value && typeof value === "object" && !Array.isArray(value) - ? Object.keys(value).sort().reduce>((o, k) => { - o[k] = value[k]; - return o; - }, {}) - : value - )); - return h.digest("hex").slice(0, 16); -} - -/** - * Record a tool call and check if it should be blocked. - * - * Returns `{ block: false }` for allowed calls. - * Returns `{ block: true, reason }` when the loop threshold is exceeded. - */ -export function checkToolCallLoop( - toolName: string, - args: Record, -): { block: boolean; reason?: string; count?: number } { - if (!enabled) return { block: false, count: 0 }; - - const sig = hashToolCall(toolName, args); - - if (sig === lastSignature) { - consecutiveCount++; - } else { - consecutiveCount = 1; - lastSignature = sig; - lastToolName = toolName; - } - - const threshold = STRICT_LOOP_TOOLS.has(toolName) - ? MAX_CONSECUTIVE_STRICT - : MAX_CONSECUTIVE_IDENTICAL_CALLS; - - if (consecutiveCount > threshold) { - return { - block: true, - reason: - `Tool loop detected: ${toolName} called ${consecutiveCount} times ` + - `with identical arguments. Blocking to prevent infinite loop. ` + - `Try a different approach or modify your arguments.`, - count: consecutiveCount, - }; - } - - return { block: false, count: consecutiveCount }; -} - -/** Reset the guard state. Call at agent turn boundaries. */ -export function resetToolCallLoopGuard(): void { - consecutiveCount = 0; - lastSignature = ""; - lastToolName = ""; - enabled = true; -} - -/** Disable the guard (e.g. during shutdown). */ -export function disableToolCallLoopGuard(): void { - enabled = false; - consecutiveCount = 0; - lastSignature = ""; - lastToolName = ""; -} - -/** Get current consecutive count for diagnostics. */ -export function getToolCallLoopCount(): number { - return consecutiveCount; -} diff --git a/src/resources/extensions/gsd/bootstrap/write-gate.ts b/src/resources/extensions/gsd/bootstrap/write-gate.ts deleted file mode 100644 index 5d446515e..000000000 --- a/src/resources/extensions/gsd/bootstrap/write-gate.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/; -const CONTEXT_MILESTONE_RE = /(?:^|[/\\])(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/i; -const DEPTH_VERIFICATION_MILESTONE_RE = /depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i; - -/** - * Path segment that identifies .gsd/ planning artifacts. - * Writes to these paths are allowed during queue mode. - */ -const SF_DIR_RE = /(^|[/\\])\.gsd([/\\]|$)/; - -/** - * Read-only tool names that are always safe during queue mode. - */ -const QUEUE_SAFE_TOOLS = new Set([ - "read", "grep", "find", "ls", "glob", - // Discussion & planning tools - "ask_user_questions", - "gsd_milestone_generate_id", - "gsd_summary_save", - // Web research tools used during queue discussion - "search-the-web", "resolve_library", "get_library_docs", "fetch_page", - "search_and_read", -]); - -/** - * Bash commands that are read-only / investigative — safe during queue mode. - * Matches the leading command in a bash invocation. - */ -const BASH_READ_ONLY_RE = /^\s*(cat|head|tail|less|more|wc|file|stat|du|df|which|type|echo|printf|ls|find|grep|rg|awk|sed\b(?!.*-i)|sort|uniq|diff|comm|tr|cut|tee\s+-a\s+\/dev\/null|git\s+(log|show|diff|status|branch|tag|remote|rev-parse|ls-files|blame|shortlog|describe|stash\s+list|config\s+--get|cat-file)|gh\s+(issue|pr|api|repo|release)\s+(view|list|diff|status|checks)|mkdir\s+-p\s+\.gsd|rtk\s)/; - -const verifiedDepthMilestones = new Set(); -let activeQueuePhase = false; - -/** - * Discussion gate enforcement state. - * - * When ask_user_questions is called with a recognized gate question ID, - * we track the pending gate. Until the gate is confirmed (user selects the - * first/recommended option), all non-read-only tool calls are blocked. - * This mechanically prevents the model from rationalizing past failed or - * cancelled gate questions. - */ -let pendingGateId: string | null = null; - -/** - * Recognized gate question ID patterns. - * These appear in discuss.md (depth/requirements/roadmap). - */ -const GATE_QUESTION_PATTERNS = [ - "depth_verification", -] as const; - -/** - * Tools that are safe to call while a gate is pending. - * Includes read-only tools and ask_user_questions itself (so the model can re-ask). - */ -const GATE_SAFE_TOOLS = new Set([ - "ask_user_questions", - "read", "grep", "find", "ls", "glob", - "search-the-web", "resolve_library", "get_library_docs", "fetch_page", - "search_and_read", -]); - -export interface WriteGateSnapshot { - verifiedDepthMilestones: string[]; - activeQueuePhase: boolean; - pendingGateId: string | null; -} - -function shouldPersistWriteGateSnapshot(env: NodeJS.ProcessEnv = process.env): boolean { - return env.SF_PERSIST_WRITE_GATE_STATE === "1"; -} - -function writeGateSnapshotPath(basePath: string = process.cwd()): string { - return join(basePath, ".gsd", "runtime", "write-gate-state.json"); -} - -function currentWriteGateSnapshot(): WriteGateSnapshot { - return { - verifiedDepthMilestones: [...verifiedDepthMilestones].sort(), - activeQueuePhase, - pendingGateId, - }; -} - -function persistWriteGateSnapshot(basePath: string = process.cwd()): void { - if (!shouldPersistWriteGateSnapshot()) return; - const path = writeGateSnapshotPath(basePath); - mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true }); - const tempPath = `${path}.tmp`; - writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8"); - renameSync(tempPath, path); -} - -function clearPersistedWriteGateSnapshot(basePath: string = process.cwd()): void { - if (!shouldPersistWriteGateSnapshot()) return; - const path = writeGateSnapshotPath(basePath); - try { - unlinkSync(path); - } catch { - // swallow - } -} - -function normalizeWriteGateSnapshot(value: unknown): WriteGateSnapshot { - const record = value && typeof value === "object" ? value as Record : {}; - const verified = Array.isArray(record.verifiedDepthMilestones) - ? record.verifiedDepthMilestones.filter((item): item is string => typeof item === "string") - : []; - return { - verifiedDepthMilestones: [...new Set(verified)].sort(), - activeQueuePhase: record.activeQueuePhase === true, - pendingGateId: typeof record.pendingGateId === "string" ? record.pendingGateId : null, - }; -} - -export function loadWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot { - const path = writeGateSnapshotPath(basePath); - if (!existsSync(path)) return currentWriteGateSnapshot(); - try { - return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8"))); - } catch { - return currentWriteGateSnapshot(); - } -} - -export function isDepthVerified(): boolean { - return verifiedDepthMilestones.size > 0; -} - -/** - * Check whether a specific milestone has passed depth verification. - */ -export function isMilestoneDepthVerified(milestoneId: string | null | undefined): boolean { - if (!milestoneId) return false; - return verifiedDepthMilestones.has(milestoneId); -} - -export function isMilestoneDepthVerifiedInSnapshot( - snapshot: WriteGateSnapshot, - milestoneId: string | null | undefined, -): boolean { - if (!milestoneId) return false; - return snapshot.verifiedDepthMilestones.includes(milestoneId); -} - -export function isQueuePhaseActive(): boolean { - return activeQueuePhase; -} - -export function setQueuePhaseActive(active: boolean): void { - activeQueuePhase = active; - persistWriteGateSnapshot(); -} - -export function resetWriteGateState(): void { - verifiedDepthMilestones.clear(); - pendingGateId = null; - persistWriteGateSnapshot(); -} - -export function clearDiscussionFlowState(): void { - verifiedDepthMilestones.clear(); - activeQueuePhase = false; - pendingGateId = null; - clearPersistedWriteGateSnapshot(); -} - -export function markDepthVerified(milestoneId?: string | null, basePath: string = process.cwd()): void { - if (!milestoneId) return; - verifiedDepthMilestones.add(milestoneId); - persistWriteGateSnapshot(basePath); -} - -/** - * Check whether a question ID matches a recognized gate pattern. - */ -export function isGateQuestionId(questionId: string): boolean { - return GATE_QUESTION_PATTERNS.some(pattern => questionId.includes(pattern)); -} - -/** - * Extract the milestone ID embedded in a depth-verification question id. - * Prompts are expected to use ids like `depth_verification_M001_confirm`. - */ -export function extractDepthVerificationMilestoneId(questionId: string): string | null { - const match = questionId.match(DEPTH_VERIFICATION_MILESTONE_RE); - return match?.[1] ?? null; -} - -/** - * Extract the milestone ID from a milestone CONTEXT file path. - */ -function extractContextMilestoneId(inputPath: string): string | null { - const match = inputPath.match(CONTEXT_MILESTONE_RE); - return match?.[1] ?? null; -} - -/** - * Mark a gate as pending (called when ask_user_questions is invoked with a gate ID). - */ -export function setPendingGate(gateId: string): void { - pendingGateId = gateId; - persistWriteGateSnapshot(); -} - -/** - * Clear the pending gate (called when the user confirms). - */ -export function clearPendingGate(): void { - pendingGateId = null; - persistWriteGateSnapshot(); -} - -/** - * Get the currently pending gate, if any. - */ -export function getPendingGate(): string | null { - return pendingGateId; -} - -/** - * Check whether a tool call should be blocked because a discussion gate - * is pending (ask_user_questions was called but not confirmed). - * - * Returns { block: true, reason } if the tool should be blocked. - * Read-only tools and ask_user_questions itself are always allowed. - */ -export function shouldBlockPendingGate( - toolName: string, - milestoneId: string | null, - queuePhaseActive?: boolean, -): { block: boolean; reason?: string } { - return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(), toolName, milestoneId, queuePhaseActive); -} - -export function shouldBlockPendingGateInSnapshot( - snapshot: WriteGateSnapshot, - toolName: string, - _milestoneId: string | null, - _queuePhaseActive?: boolean, -): { block: boolean; reason?: string } { - if (!snapshot.pendingGateId) return { block: false }; - - if (GATE_SAFE_TOOLS.has(toolName)) return { block: false }; - - // Bash read-only commands are also safe - if (toolName === "bash") return { block: false }; // bash is checked separately below - - return { - block: true, - reason: [ - `HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`, - `You MUST re-call ask_user_questions with the gate question before making any other tool calls.`, - `If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`, - `did not match a provided option, you MUST re-ask — never rationalize past the block.`, - `Do NOT proceed, do NOT use alternative approaches, do NOT skip the gate.`, - ].join(" "), - }; -} - -/** - * Check whether a bash command should be blocked because a discussion gate is pending. - * Read-only bash commands are allowed; mutating commands are blocked. - */ -export function shouldBlockPendingGateBash( - command: string, - milestoneId: string | null, - queuePhaseActive?: boolean, -): { block: boolean; reason?: string } { - return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(), command, milestoneId, queuePhaseActive); -} - -export function shouldBlockPendingGateBashInSnapshot( - snapshot: WriteGateSnapshot, - command: string, - _milestoneId: string | null, - _queuePhaseActive?: boolean, -): { block: boolean; reason?: string } { - if (!snapshot.pendingGateId) return { block: false }; - - // Allow read-only bash commands - if (BASH_READ_ONLY_RE.test(command)) return { block: false }; - - return { - block: true, - reason: [ - `HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`, - `You MUST re-call ask_user_questions with the gate question before running mutating commands.`, - `If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`, - `did not match a provided option, you MUST re-ask — never rationalize past the block.`, - ].join(" "), - }; -} - -/** - * Check whether a depth_verification answer confirms the discussion is complete. - * Uses structural validation: the selected answer must exactly match the first - * option label from the question definition (the confirmation option by convention). - * This rejects free-form "Other" text, decline options, and garbage input without - * coupling to any specific label substring. - * - * @param selected The answer's selected value from details.response.answers[id].selected - * @param options The question's options array from event.input.questions[n].options - */ -export function isDepthConfirmationAnswer( - selected: unknown, - options?: Array<{ label?: string }>, -): boolean { - const value = Array.isArray(selected) ? selected[0] : selected; - if (typeof value !== "string" || !value) return false; - - // If options are available, structurally validate: selected must exactly match - // the first option (confirmation) label. Rejects free-form "Other" and decline options. - if (Array.isArray(options) && options.length > 0) { - const confirmLabel = options[0]?.label; - return typeof confirmLabel === "string" && value === confirmLabel; - } - - // Fallback when options aren't available (e.g., older call sites): - // accept only if it contains "(Recommended)" — the prompt convention suffix. - return value.includes("(Recommended)"); -} - -export function shouldBlockContextWrite( - toolName: string, - inputPath: string, - milestoneId: string | null, - _queuePhaseActive?: boolean, -): { block: boolean; reason?: string } { - if (toolName !== "write") return { block: false }; - if (!MILESTONE_CONTEXT_RE.test(inputPath)) return { block: false }; - - const targetMilestoneId = extractContextMilestoneId(inputPath) ?? milestoneId; - if (!targetMilestoneId) { - return { - block: true, - reason: [ - `HARD BLOCK: Cannot write milestone CONTEXT.md without knowing which milestone it belongs to.`, - `This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`, - `Required action: call ask_user_questions with question id containing "depth_verification" and the milestone id.`, - ].join(" "), - }; - } - - if (isMilestoneDepthVerified(targetMilestoneId)) return { block: false }; - - return { - block: true, - reason: [ - `HARD BLOCK: Cannot write to milestone CONTEXT.md without depth verification.`, - `This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`, - `Required action: call ask_user_questions with question id containing "depth_verification".`, - `The user MUST select the "(Recommended)" confirmation option to unlock this gate.`, - `If the user declines, cancels, or the tool fails, you must re-ask — not bypass.`, - ].join(" "), - }; -} - -/** - * Check whether a gsd_summary_save CONTEXT artifact should be blocked. - * Slice-level CONTEXT artifacts are allowed; milestone-level CONTEXT writes - * require the milestone to be depth-verified first. - */ -export function shouldBlockContextArtifactSave( - artifactType: string, - milestoneId: string | null, - sliceId?: string | null, -): { block: boolean; reason?: string } { - return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(), artifactType, milestoneId, sliceId); -} - -export function shouldBlockContextArtifactSaveInSnapshot( - snapshot: WriteGateSnapshot, - artifactType: string, - milestoneId: string | null, - sliceId?: string | null, -): { block: boolean; reason?: string } { - if (artifactType !== "CONTEXT") return { block: false }; - if (sliceId) return { block: false }; - if (!milestoneId) { - return { - block: true, - reason: [ - `HARD BLOCK: Cannot save milestone CONTEXT without a milestone_id.`, - `This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`, - ].join(" "), - }; - } - if (isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId)) return { block: false }; - - return { - block: true, - reason: [ - `HARD BLOCK: Cannot save milestone CONTEXT without depth verification for ${milestoneId}.`, - `This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`, - `Required action: call ask_user_questions with question id containing "depth_verification_${milestoneId}".`, - `The user MUST select the "(Recommended)" confirmation option to unlock this gate.`, - ].join(" "), - }; -} - -/** - * Queue-mode execution guard (#2545). - * - * When the queue phase is active, the agent should only create planning - * artifacts (milestones, CONTEXT.md, QUEUE.md, etc.) — never execute work. - * This function blocks write/edit/bash tool calls that would modify source - * code outside of .gsd/. - * - * @param toolName The tool being called (write, edit, bash, etc.) - * @param input For write/edit: the file path. For bash: the command string. - * @param queuePhaseActive Whether the queue phase is currently active. - * @returns { block, reason } — block=true if the call should be rejected. - */ -export function shouldBlockQueueExecution( - toolName: string, - input: string, - queuePhaseActive: boolean, -): { block: boolean; reason?: string } { - return shouldBlockQueueExecutionInSnapshot(currentWriteGateSnapshot(), toolName, input, queuePhaseActive); -} - -export function shouldBlockQueueExecutionInSnapshot( - snapshot: WriteGateSnapshot, - toolName: string, - input: string, - queuePhaseActive: boolean = snapshot.activeQueuePhase, -): { block: boolean; reason?: string } { - if (!queuePhaseActive) return { block: false }; - - // Always-safe tools (read-only, discussion, planning) - if (QUEUE_SAFE_TOOLS.has(toolName)) return { block: false }; - - // write/edit — allow if targeting .gsd/ planning artifacts - if (toolName === "write" || toolName === "edit") { - if (SF_DIR_RE.test(input)) return { block: false }; - return { - block: true, - reason: `Blocked: /gsd queue is a planning tool — it creates milestones, not executes work. ` + - `Cannot ${toolName} to "${input}" during queue mode. ` + - `Write CONTEXT.md files and update PROJECT.md/QUEUE.md instead.`, - }; - } - - // bash — allow read-only/investigative commands, block everything else - if (toolName === "bash") { - if (BASH_READ_ONLY_RE.test(input)) return { block: false }; - return { - block: true, - reason: `Blocked: /gsd queue is a planning tool — it creates milestones, not executes work. ` + - `Cannot run "${input.slice(0, 80)}${input.length > 80 ? "…" : ""}" during queue mode. ` + - `Use read-only commands (cat, grep, git log, etc.) to investigate, then write planning artifacts.`, - }; - } - - // Unknown tools — block by default in queue mode so custom tools cannot - // bypass execution restrictions. - return { - block: true, - reason: `Blocked: /gsd queue is a planning tool — it creates milestones, not executes work. Unknown tools are not permitted during queue mode.`, - }; -} diff --git a/src/resources/extensions/gsd/branch-patterns.ts b/src/resources/extensions/gsd/branch-patterns.ts deleted file mode 100644 index 56225abf9..000000000 --- a/src/resources/extensions/gsd/branch-patterns.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * SF branch naming patterns — single source of truth. - * - * gsd/// → SLICE_BRANCH_RE - * gsd/quick/- → QUICK_BRANCH_RE - * gsd//<...> → WORKFLOW_BRANCH_RE (non-milestone gsd/ branches) - */ - -/** Matches gsd/ slice branches: gsd/[worktree/]M001[-hash]/S01 */ -export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/; - -/** Matches gsd/quick/ task branches */ -export const QUICK_BRANCH_RE = /^gsd\/quick\//; - -/** Matches gsd/ workflow branches (non-milestone, e.g. gsd/workflow-name/...) */ -export const WORKFLOW_BRANCH_RE = /^gsd\/(?!M\d)[\w-]+\//; diff --git a/src/resources/extensions/gsd/cache.ts b/src/resources/extensions/gsd/cache.ts deleted file mode 100644 index ed5330d5b..000000000 --- a/src/resources/extensions/gsd/cache.ts +++ /dev/null @@ -1,29 +0,0 @@ -// SF Extension — Cache Invalidation -// -// Three module-scoped caches exist across the SF extension: -// 1. State cache (state.ts) — memoized deriveState() result -// 2. Path cache (paths.ts) — directory listing results (readdirSync) -// 3. Parse cache (files.ts) — parsed markdown file results -// -// After any file write that changes .gsd/ contents, all three must be -// invalidated together to prevent stale reads. This module provides a -// single function that clears all three atomically. - -import { invalidateStateCache } from './state.js'; -import { clearPathCache } from './paths.js'; -import { clearParseCache } from './files.js'; -import { clearArtifacts } from './gsd-db.js'; - -/** - * Invalidate all SF runtime caches in one call. - * - * Call this after file writes, milestone transitions, merge reconciliation, - * or any operation that changes .gsd/ contents on disk. Forgetting to clear - * any single cache causes stale reads (see #431, #793). - */ -export function invalidateAllCaches(): void { - invalidateStateCache(); - clearPathCache(); - clearParseCache(); - clearArtifacts(); -} diff --git a/src/resources/extensions/gsd/captures.ts b/src/resources/extensions/gsd/captures.ts deleted file mode 100644 index 66db90c6c..000000000 --- a/src/resources/extensions/gsd/captures.ts +++ /dev/null @@ -1,571 +0,0 @@ -/** - * SF Captures — Fire-and-forget thought capture with triage classification - * - * Append-only capture file at `.gsd/CAPTURES.md`. Each capture is an H3 section - * with bold metadata fields, parseable by the same patterns used in files.ts. - * - * Worktree-aware: captures always resolve to the original project root's - * `.gsd/CAPTURES.md`, not the worktree's local `.gsd/`. - */ - -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, resolve, sep } from "node:path"; -import { randomUUID } from "node:crypto"; -import { gsdRoot } from "./paths.js"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export type Classification = "quick-task" | "inject" | "defer" | "replan" | "note" | "stop" | "backtrack"; - -export interface CaptureEntry { - id: string; - text: string; - timestamp: string; - status: "pending" | "triaged" | "resolved"; - classification?: Classification; - resolution?: string; - rationale?: string; - resolvedAt?: string; - resolvedInMilestone?: string; - executed?: boolean; -} - -export interface TriageResult { - captureId: string; - classification: Classification; - rationale: string; - affectedFiles?: string[]; - targetSlice?: string; -} - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const CAPTURES_FILENAME = "CAPTURES.md"; -const VALID_CLASSIFICATIONS: readonly string[] = [ - "quick-task", "inject", "defer", "replan", "note", "stop", "backtrack", -]; - -// ─── Path Resolution ────────────────────────────────────────────────────────── - -/** - * Resolve the path to CAPTURES.md, aware of worktree context. - * - * In worktree-isolated mode, basePath is `.gsd/worktrees//`. - * Captures must resolve to the *original* project root's `.gsd/CAPTURES.md`, - * not the worktree-local `.gsd/`. This ensures all captures go to one file - * regardless of which worktree the agent is running in. - * - * Detection: if basePath contains `/.gsd/worktrees/`, walk up to the - * directory that contains `.gsd/worktrees/` — that's the project root. - */ -export function resolveCapturesPath(basePath: string): string { - const resolved = resolve(basePath); - // Direct layout: /.gsd/worktrees/ - const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`; - let idx = resolved.indexOf(worktreeMarker); - if (idx === -1) { - // Symlink-resolved layout: /.gsd/projects//worktrees/ - const symlinkRe = new RegExp( - `\\${sep}\\.gsd\\${sep}projects\\${sep}[a-f0-9]+\\${sep}worktrees\\${sep}`, - ); - const match = resolved.match(symlinkRe); - if (match && match.index !== undefined) idx = match.index; - } - if (idx !== -1) { - // basePath is inside a worktree — resolve to project root - const projectRoot = resolved.slice(0, idx); - return join(projectRoot, ".gsd", CAPTURES_FILENAME); - } - return join(gsdRoot(basePath), CAPTURES_FILENAME); -} - -// ─── File I/O ───────────────────────────────────────────────────────────────── - -/** - * Append a new capture entry to CAPTURES.md. - * Creates `.gsd/` and the file if they don't exist. - * Returns the generated capture ID. - */ -export function appendCapture(basePath: string, text: string): string { - const filePath = resolveCapturesPath(basePath); - const dir = join(filePath, ".."); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const id = `CAP-${randomUUID().slice(0, 8)}`; - const timestamp = new Date().toISOString(); - - const entry = [ - `### ${id}`, - `**Text:** ${text}`, - `**Captured:** ${timestamp}`, - `**Status:** pending`, - "", - ].join("\n"); - - if (existsSync(filePath)) { - const existing = readFileSync(filePath, "utf-8"); - writeFileSync(filePath, existing.trimEnd() + "\n\n" + entry, "utf-8"); - } else { - const header = `# Captures\n\n`; - writeFileSync(filePath, header + entry, "utf-8"); - } - - return id; -} - -/** - * Parse all capture entries from CAPTURES.md. - * Returns entries in file order (oldest first). - */ -export function loadAllCaptures(basePath: string): CaptureEntry[] { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, "utf-8"); - return parseCapturesContent(content); -} - -/** - * Load only pending (unresolved) captures. - */ -export function loadPendingCaptures(basePath: string): CaptureEntry[] { - return loadAllCaptures(basePath).filter(c => c.status === "pending"); -} - -/** - * Fast check for pending captures without full parse. - * Reads the file and scans for `**Status:** pending` via regex. - * Returns false if the file doesn't exist. - */ -export function hasPendingCaptures(basePath: string): boolean { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return false; - try { - const content = readFileSync(filePath, "utf-8"); - return /\*\*Status:\*\*\s*pending/i.test(content); - } catch { - return false; - } -} - -/** - * Count pending captures without full parse — single file read. - * Uses regex to count `**Status:** pending` occurrences. - * Returns 0 if file doesn't exist or on error. - */ -export function countPendingCaptures(basePath: string): number { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return 0; - try { - const content = readFileSync(filePath, "utf-8"); - const matches = content.match(/\*\*Status:\*\*\s*pending/gi); - return matches ? matches.length : 0; - } catch { - return 0; - } -} - -/** - * Mark a capture as resolved with classification and rationale. - * Rewrites the entry in place, preserving other entries. - */ -export function markCaptureResolved( - basePath: string, - captureId: string, - classification: Classification, - resolution: string, - rationale: string, - milestoneId?: string, -): void { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return; - - const content = readFileSync(filePath, "utf-8"); - const resolvedAt = new Date().toISOString(); - - // Find the section for this capture ID and rewrite its fields - const sectionRegex = new RegExp( - `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`, - "s", - ); - const match = sectionRegex.exec(content); - if (!match) return; - - let section = match[1]; - - // Update Status field - section = section.replace( - /\*\*Status:\*\*\s*.+/, - `**Status:** resolved`, - ); - - // Append classification, resolution, rationale, and timestamp if not present - const newFields = [ - `**Classification:** ${classification}`, - `**Resolution:** ${resolution}`, - `**Rationale:** ${rationale}`, - `**Resolved:** ${resolvedAt}`, - ]; - if (milestoneId) { - newFields.push(`**Milestone:** ${milestoneId}`); - } - - // Remove any existing classification/resolution/rationale/resolved/milestone fields - // (in case of re-triage) - section = section.replace(/\*\*Classification:\*\*\s*.+\n?/g, ""); - section = section.replace(/\*\*Resolution:\*\*\s*.+\n?/g, ""); - section = section.replace(/\*\*Rationale:\*\*\s*.+\n?/g, ""); - section = section.replace(/\*\*Resolved:\*\*\s*.+\n?/g, ""); - section = section.replace(/\*\*Milestone:\*\*\s*.+\n?/g, ""); - - // Add new fields after Status line - section = section.trimEnd() + "\n" + newFields.join("\n") + "\n"; - - const updated = content.replace(sectionRegex, section); - writeFileSync(filePath, updated, "utf-8"); -} - -/** - * Mark a resolved capture as executed — its resolution action was carried out. - * Appends `**Executed:** ` to the capture's section in CAPTURES.md. - */ -export function markCaptureExecuted(basePath: string, captureId: string): void { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return; - - const content = readFileSync(filePath, "utf-8"); - const executedAt = new Date().toISOString(); - - const sectionRegex = new RegExp( - `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`, - "s", - ); - const match = sectionRegex.exec(content); - if (!match) return; - - let section = match[1]; - - // Remove any existing Executed field (in case of re-execution) - section = section.replace(/\*\*Executed:\*\*\s*.+\n?/g, ""); - - // Append Executed timestamp - section = section.trimEnd() + "\n" + `**Executed:** ${executedAt}` + "\n"; - - const updated = content.replace(sectionRegex, section); - writeFileSync(filePath, updated, "utf-8"); -} - -/** - * Load resolved captures that have actionable classifications (inject, replan, - * quick-task) but have NOT yet been executed. - * These are captures whose resolutions need to be carried out. - * - * When `currentMilestoneId` is provided, captures resolved in a *different* - * milestone are treated as stale and excluded. This prevents quick-task - * captures from a prior milestone re-executing after the underlying issues - * were already fixed by planned milestone work (#2872). - * - * Captures that have no `resolvedInMilestone` (legacy captures resolved before - * this field was introduced) are always included for backward compatibility. - */ -export function loadActionableCaptures(basePath: string, currentMilestoneId?: string): CaptureEntry[] { - return loadAllCaptures(basePath).filter( - c => - c.status === "resolved" && - !c.executed && - (c.classification === "inject" || - c.classification === "replan" || - c.classification === "quick-task") && - // Staleness gate: exclude captures resolved in a different milestone (#2872) - (!currentMilestoneId || - !c.resolvedInMilestone || - c.resolvedInMilestone === currentMilestoneId), - ); -} - -/** - * Load unexecuted stop captures — user directives to halt auto-mode. - * These are checked in the pre-dispatch guard pipeline (runGuards) to - * pause auto-mode before the next unit is dispatched. - */ -export function loadStopCaptures(basePath: string): CaptureEntry[] { - return loadAllCaptures(basePath).filter( - c => c.status === "resolved" && !c.executed && - (c.classification === "stop" || c.classification === "backtrack"), - ); -} - -/** - * Load unexecuted backtrack captures specifically — captures directing - * auto-mode to abandon current milestone and return to a previous one. - */ -export function loadBacktrackCaptures(basePath: string): CaptureEntry[] { - return loadAllCaptures(basePath).filter( - c => c.status === "resolved" && !c.executed && c.classification === "backtrack", - ); -} - -/** - * Revert captures that were silenced by non-triage agents. - * - * When an execute-task or other non-triage agent writes `**Status:** resolved` - * to CAPTURES.md, it bypasses the triage pipeline entirely. This function - * detects such captures (resolved but missing the Classification field that - * triage always writes) and reverts them to pending so the triage sidecar - * picks them up properly. - * - * Returns the number of captures reverted. - */ -export function revertExecutorResolvedCaptures(basePath: string): number { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return 0; - - let content = readFileSync(filePath, "utf-8"); - let reverted = 0; - - const all = loadAllCaptures(basePath); - for (const capture of all) { - // A properly triaged capture has both resolved status AND a classification. - // An executor-silenced capture has resolved status but NO classification. - if (capture.status === "resolved" && !capture.classification) { - const sectionRegex = new RegExp( - `(### ${escapeRegex(capture.id)}\\n(?:(?!### ).)*?)(?=### |$)`, - "s", - ); - const match = sectionRegex.exec(content); - if (match) { - let section = match[1]; - section = section.replace( - /\*\*Status:\*\*\s*resolved/i, - "**Status:** pending", - ); - content = content.replace(sectionRegex, section); - reverted++; - } - } - } - - if (reverted > 0) { - writeFileSync(filePath, content, "utf-8"); - } - - return reverted; -} - -/** - * Retroactively stamp a capture with a milestone ID. - * - * Used by executeTriageResolutions() as a safety net when the triage LLM - * resolves a capture without writing the **Milestone:** field. This ensures - * the staleness gate in loadActionableCaptures() works correctly even for - * captures resolved before the prompt was updated (#2872). - */ -export function stampCaptureMilestone(basePath: string, captureId: string, milestoneId: string): void { - const filePath = resolveCapturesPath(basePath); - if (!existsSync(filePath)) return; - - const content = readFileSync(filePath, "utf-8"); - - const sectionRegex = new RegExp( - `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`, - "s", - ); - const match = sectionRegex.exec(content); - if (!match) return; - - let section = match[1]; - - // Only stamp if not already present - if (/\*\*Milestone:\*\*/.test(section)) return; - - // Insert after the Resolved field (or at end of section) - const resolvedFieldEnd = section.search(/\*\*Resolved:\*\*\s*.+\n?/); - if (resolvedFieldEnd !== -1) { - const resolvedMatch = section.match(/\*\*Resolved:\*\*\s*.+\n?/); - const insertPos = resolvedFieldEnd + (resolvedMatch?.[0]?.length ?? 0); - section = section.slice(0, insertPos) + `**Milestone:** ${milestoneId}\n` + section.slice(insertPos); - } else { - section = section.trimEnd() + "\n" + `**Milestone:** ${milestoneId}` + "\n"; - } - - const updated = content.replace(sectionRegex, section); - writeFileSync(filePath, updated, "utf-8"); -} - -// ─── Parser ─────────────────────────────────────────────────────────────────── - -/** - * Parse CAPTURES.md content into CaptureEntry array. - */ -function parseCapturesContent(content: string): CaptureEntry[] { - const entries: CaptureEntry[] = []; - - // Split on H3 headings - const sections = content.split(/^### /m).slice(1); // skip content before first H3 - - for (const section of sections) { - const lines = section.split("\n"); - const id = lines[0]?.trim(); - if (!id) continue; - - const body = lines.slice(1).join("\n"); - const text = extractBoldField(body, "Text"); - const timestamp = extractBoldField(body, "Captured"); - const statusRaw = extractBoldField(body, "Status"); - const classification = extractBoldField(body, "Classification") as Classification | null; - const resolution = extractBoldField(body, "Resolution"); - const rationale = extractBoldField(body, "Rationale"); - const resolvedAt = extractBoldField(body, "Resolved"); - const milestoneId = extractBoldField(body, "Milestone"); - const executedAt = extractBoldField(body, "Executed"); - - if (!text || !timestamp) continue; - - const status = (statusRaw === "resolved" || statusRaw === "triaged") - ? statusRaw - : "pending"; - - entries.push({ - id, - text, - timestamp, - status, - ...(classification && VALID_CLASSIFICATIONS.includes(classification) ? { classification } : {}), - ...(resolution ? { resolution } : {}), - ...(rationale ? { rationale } : {}), - ...(resolvedAt ? { resolvedAt } : {}), - ...(milestoneId ? { resolvedInMilestone: milestoneId } : {}), - ...(executedAt ? { executed: true } : {}), - }); - } - - return entries; -} - -/** - * Extract value from a bold-prefixed line like "**Key:** Value". - * Local copy of the pattern from files.ts to keep this module self-contained. - */ -function extractBoldField(text: string, key: string): string | null { - const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, "m"); - const match = regex.exec(text); - return match ? match[1].trim() : null; -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// ─── Triage Output Parser ───────────────────────────────────────────────────── - -/** - * Parse LLM triage output into TriageResult array. - * - * Handles: - * - Clean JSON array - * - JSON wrapped in fenced code block (```json ... ```) - * - JSON with leading/trailing prose - * - Single object (not array) — wraps in array - * - Malformed JSON — returns empty array (caller should fall back to note) - * - Partial results — valid entries are kept, invalid skipped - */ -export function parseTriageOutput(llmResponse: string): TriageResult[] { - if (!llmResponse || !llmResponse.trim()) return []; - - // Try to extract JSON from fenced code blocks first - const fenced = llmResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); - const jsonStr = fenced ? fenced[1] : extractJsonSubstring(llmResponse); - - if (!jsonStr) return []; - - try { - const parsed = JSON.parse(jsonStr); - const arr = Array.isArray(parsed) ? parsed : [parsed]; - return arr - .filter(isValidTriageResult) - .map(normalizeTriageResult); - } catch { - return []; - } -} - -/** - * Try to find a JSON array or object substring in prose text. - * Looks for the first [ or { and finds its matching bracket. - */ -function extractJsonSubstring(text: string): string | null { - // Find first [ or { - const arrStart = text.indexOf("["); - const objStart = text.indexOf("{"); - - let start: number; - let openChar: string; - let closeChar: string; - - if (arrStart === -1 && objStart === -1) return null; - if (arrStart === -1) { - start = objStart; - openChar = "{"; - closeChar = "}"; - } else if (objStart === -1) { - start = arrStart; - openChar = "["; - closeChar = "]"; - } else { - start = Math.min(arrStart, objStart); - openChar = start === arrStart ? "[" : "{"; - closeChar = start === arrStart ? "]" : "}"; - } - - // Find matching bracket - let depth = 0; - let inString = false; - let escape = false; - - for (let i = start; i < text.length; i++) { - const ch = text[i]; - if (escape) { - escape = false; - continue; - } - if (ch === "\\") { - escape = true; - continue; - } - if (ch === '"') { - inString = !inString; - continue; - } - if (inString) continue; - if (ch === openChar) depth++; - if (ch === closeChar) depth--; - if (depth === 0) { - return text.slice(start, i + 1); - } - } - - return null; -} - -function isValidTriageResult(obj: unknown): boolean { - if (!obj || typeof obj !== "object") return false; - const o = obj as Record; - return ( - typeof o.captureId === "string" && - typeof o.classification === "string" && - VALID_CLASSIFICATIONS.includes(o.classification) && - typeof o.rationale === "string" - ); -} - -function normalizeTriageResult(obj: Record): TriageResult { - return { - captureId: obj.captureId as string, - classification: obj.classification as Classification, - rationale: obj.rationale as string, - ...(Array.isArray(obj.affectedFiles) ? { affectedFiles: obj.affectedFiles as string[] } : {}), - ...(typeof obj.targetSlice === "string" ? { targetSlice: obj.targetSlice } : {}), - }; -} diff --git a/src/resources/extensions/gsd/changelog.ts b/src/resources/extensions/gsd/changelog.ts deleted file mode 100644 index 2cf49deb9..000000000 --- a/src/resources/extensions/gsd/changelog.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * SF Changelog — Fetch and display categorized release notes from GitHub - * - * Fetches releases from the singularity-forge/sf-run GitHub repository, - * prompts the user for a version filter, and sends raw release notes - * into the conversation for the LLM to summarize. - * - * Entry point: handleChangelog() called from commands.ts - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface GitHubRelease { - tag_name: string; - name: string; - body: string; -} - -// ─── Semver comparison ──────────────────────────────────────────────────────── - -function compareSemver(a: string, b: string): number { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const va = pa[i] || 0; - const vb = pb[i] || 0; - if (va > vb) return 1; - if (va < vb) return -1; - } - return 0; -} - -function stripV(tag: string): string { - return tag.startsWith("v") ? tag.slice(1) : tag; -} - -// ─── Body parsing ───────────────────────────────────────────────────────────── - -interface CategorySection { - heading: string; - content: string; -} - -function parseReleaseBody(body: string): CategorySection[] { - if (!body) return []; - - const sections: CategorySection[] = []; - const lines = body.split("\n"); - let currentHeading: string | null = null; - let currentLines: string[] = []; - - for (const line of lines) { - if (line.startsWith("### ")) { - if (currentHeading !== null) { - const content = currentLines.join("\n").trim(); - if (content) { - sections.push({ heading: currentHeading, content }); - } - } - currentHeading = line.slice(4).trim(); - currentLines = []; - } else if (currentHeading !== null) { - currentLines.push(line); - } - } - - if (currentHeading !== null) { - const content = currentLines.join("\n").trim(); - if (content) { - sections.push({ heading: currentHeading, content }); - } - } - - return sections; -} - -// ─── Display formatting ────────────────────────────────────────────────────── - -function formatRelease(release: GitHubRelease): string { - const version = stripV(release.tag_name); - const title = release.name || `v${version}`; - const sections = parseReleaseBody(release.body); - - const parts: string[] = [`## ${title}`]; - - if (sections.length === 0) { - if (release.body?.trim()) { - parts.push(release.body.trim()); - } else { - parts.push("_No release notes._"); - } - } else { - for (const section of sections) { - parts.push(`### ${section.heading}`); - parts.push(section.content); - } - } - - return parts.join("\n\n"); -} - -// ─── Entry Point ────────────────────────────────────────────────────────────── - -const RELEASES_URL = "https://api.github.com/repos/singularity-forge/sf-run/releases?per_page=100"; - -export async function handleChangelog( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise { - // ── Fetch releases ────────────────────────────────────────────────────── - let releases: GitHubRelease[]; - try { - const response = await fetch(RELEASES_URL, { - headers: { "User-Agent": "gsd-changelog" }, - }); - - if (!response.ok) { - ctx.ui.notify( - `Failed to fetch changelog: GitHub API returned ${response.status} ${response.statusText}`, - "error", - ); - return; - } - - releases = (await response.json()) as GitHubRelease[]; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to fetch changelog: ${message}`, "error"); - return; - } - - if (!releases.length) { - ctx.ui.notify("No releases found in the repository.", "warning"); - return; - } - - // ── Determine version filter ──────────────────────────────────────────── - const currentVersion = process.env.SF_VERSION || ""; - let sinceVersion: string | undefined; - let showCurrentOnly = false; - - if (args.trim()) { - sinceVersion = stripV(args.trim()); - } else { - const input = await ctx.ui.input( - "Show changes since version:", - currentVersion || "latest", - ); - - if (input === undefined) { - return; - } - - if (input.trim() === "") { - showCurrentOnly = true; - } else { - sinceVersion = stripV(input.trim()); - } - } - - // ── Filter releases ───────────────────────────────────────────────────── - let matched: GitHubRelease[]; - - if (showCurrentOnly) { - if (!currentVersion) { - ctx.ui.notify( - "SF_VERSION is not set — cannot determine current release. Provide a version instead.", - "warning", - ); - return; - } - const found = releases.find((r) => stripV(r.tag_name) === currentVersion); - if (!found) { - ctx.ui.notify(`No release found matching current version v${currentVersion}`, "warning"); - return; - } - matched = [found]; - } else if (sinceVersion) { - matched = releases - .filter((r) => compareSemver(stripV(r.tag_name), sinceVersion!) > 0) - .sort((a, b) => compareSemver(stripV(b.tag_name), stripV(a.tag_name))); - - if (!matched.length) { - ctx.ui.notify(`No releases found since v${sinceVersion}`, "warning"); - return; - } - } else { - matched = [releases[0]]; - } - - // ── Send to LLM for summarization ─────────────────────────────────────── - const rawOutput = matched.map(formatRelease).join("\n\n---\n\n"); - const versionRange = sinceVersion - ? `since v${sinceVersion} (${matched.length} release${matched.length === 1 ? "" : "s"})` - : `for current release ${matched[0].name || matched[0].tag_name}`; - - const prompt = [ - `Here are the raw SF changelog entries ${versionRange}.`, - "Summarize the most important changes — group by category (Added, Changed, Fixed, etc.),", - "keep only the most impactful items (max 5 per category), skip trivial changes,", - "and include the version where each item appeared. Keep it concise and scannable.", - "", - rawOutput, - ].join("\n"); - - pi.sendMessage( - { customType: "gsd-changelog", content: prompt, display: true }, - { triggerTurn: true }, - ); -} diff --git a/src/resources/extensions/gsd/claude-import.ts b/src/resources/extensions/gsd/claude-import.ts deleted file mode 100644 index bf8b8787c..000000000 --- a/src/resources/extensions/gsd/claude-import.ts +++ /dev/null @@ -1,705 +0,0 @@ -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { SettingsManager, getAgentDir } from "@sf-run/pi-coding-agent"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { basename, dirname, join, relative, resolve } from "node:path"; -import { homedir } from "node:os"; -import { PluginImporter, type ImportManifestEntry } from "./plugin-importer.js"; -import type { NamespacedComponent } from "./namespaced-registry.js"; - -export interface ClaudeSkillCandidate { - type: "skill"; - name: string; - path: string; - root: string; - sourceLabel: string; -} - -export interface ClaudePluginCandidate { - type: "plugin"; - name: string; - path: string; - root: string; - sourceLabel: string; - packageName?: string; -} - -const SKIP_DIRS = new Set([ - ".git", - "node_modules", - ".worktrees", - "dist", - "build", - ".next", - ".turbo", - "cache", - ".cache", -]); - -function uniqueExistingDirs(paths: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const candidate of paths) { - const resolvedPath = resolve(candidate); - if (seen.has(resolvedPath)) continue; - seen.add(resolvedPath); - if (existsSync(resolvedPath)) out.push(resolvedPath); - } - return out; -} - -export function getClaudeSearchRoots(cwd: string): { skillRoots: string[]; pluginRoots: string[] } { - const home = homedir(); - const parent = resolve(cwd, ".."); - const grandparent = resolve(cwd, "..", ".."); - - // Claude Code user-scope skills live under ~/.claude/skills. - // Keep sibling/local clone fallbacks for developer workflows, but they are - // examples/convenience paths rather than the primary Claude storage model. - const skillRoots = uniqueExistingDirs([ - join(home, ".claude", "skills"), - join(home, "repos", "claude_skills"), - join(home, "repos", "skills"), - join(parent, "claude_skills"), - join(parent, "skills"), - join(grandparent, "claude_skills"), - join(grandparent, "skills"), - ]); - - // Anthropic docs model marketplaces as sources users add with - // `/plugin marketplace add ...`, and Claude stores those marketplaces under - // ~/.claude/plugins/marketplaces/. Installed plugin payloads are copied into - // ~/.claude/plugins/cache/. We prefer those stable Claude-managed locations - // before local example clones. - const pluginRoots = uniqueExistingDirs([ - join(home, ".claude", "plugins", "marketplaces"), - join(home, ".claude", "plugins", "cache"), - join(home, ".claude", "plugins"), - join(home, "repos", "claude-plugins-official"), - join(home, "repos", "claude_skills"), - join(parent, "claude-plugins-official"), - join(parent, "claude_skills"), - join(grandparent, "claude-plugins-official"), - join(grandparent, "claude_skills"), - ]); - - return { skillRoots, pluginRoots }; -} - -function sourceLabel(path: string): string { - const home = homedir(); - if (path.startsWith(join(home, ".claude"))) return "claude-home"; - if (path.startsWith(join(home, "repos"))) return "repos"; - return "local"; -} - -/** - * Check if a path is a marketplace directory (contains .claude-plugin/marketplace.json). - * Marketplace paths use the PluginImporter flow; non-marketplace use the legacy flat flow. - */ -function isMarketplacePath(pluginPath: string): boolean { - const marketplaceJson = join(pluginPath, ".claude-plugin", "marketplace.json"); - return existsSync(marketplaceJson); -} - -/** - * Detect which plugin roots are marketplaces and which are legacy flat paths. - * - * Claude Code stores marketplace sources under ~/.claude/plugins/marketplaces/. - * Each subdirectory (e.g. marketplaces/confluent/) is a marketplace repo that - * contains .claude-plugin/marketplace.json. The parent directory itself does not - * have a marketplace.json, so we scan one level deeper when the root isn't - * directly a marketplace. - */ -export function categorizePluginRoots(pluginRoots: string[]): { marketplaces: string[]; flat: string[] } { - const marketplaces: string[] = []; - const flat: string[] = []; - const seen = new Set(); - - for (const root of pluginRoots) { - if (isMarketplacePath(root)) { - if (!seen.has(root)) { - marketplaces.push(root); - seen.add(root); - } - } else { - // The root itself isn't a marketplace — check if it's a container of - // marketplaces (e.g. ~/.claude/plugins/marketplaces/ contains subdirs - // like confluent/, claude-hud/, each with their own marketplace.json). - let foundChild = false; - try { - const entries = readdirSync(root, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SKIP_DIRS.has(entry.name)) continue; - const childPath = join(root, entry.name); - if (isMarketplacePath(childPath) && !seen.has(childPath)) { - marketplaces.push(childPath); - seen.add(childPath); - foundChild = true; - } - } - } catch { - // Can't read directory — fall through to flat - } - if (!foundChild) { - flat.push(root); - } - } - } - - return { marketplaces, flat }; -} - -function walkDirs(root: string, visit: (dir: string, depth: number) => void, maxDepth = 4): void { - function walk(dir: string, depth: number) { - visit(dir, depth); - if (depth >= maxDepth) return; - let entries: Array<{ name: string; isDirectory: () => boolean }> = []; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SKIP_DIRS.has(entry.name)) continue; - walk(join(dir, entry.name), depth + 1); - } - } - walk(root, 0); -} - -export function discoverClaudeSkills(cwd: string): ClaudeSkillCandidate[] { - const { skillRoots } = getClaudeSearchRoots(cwd); - const results: ClaudeSkillCandidate[] = []; - const seen = new Set(); - - for (const root of skillRoots) { - walkDirs(root, (dir) => { - const skillFile = join(dir, "SKILL.md"); - if (!existsSync(skillFile)) return; - const resolvedDir = resolve(dir); - if (seen.has(resolvedDir)) return; - seen.add(resolvedDir); - results.push({ - type: "skill", - name: basename(dir), - path: resolvedDir, - root, - sourceLabel: sourceLabel(root), - }); - }, 5); - } - - return results.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); -} - -export function discoverClaudePlugins(cwd: string): ClaudePluginCandidate[] { - const { pluginRoots } = getClaudeSearchRoots(cwd); - const results: ClaudePluginCandidate[] = []; - const seen = new Set(); - - for (const root of pluginRoots) { - walkDirs(root, (dir) => { - // Recognize both npm-style plugins (package.json) and Claude Code plugins - // (.claude-plugin/plugin.json). Claude marketplace-installed plugins use - // the latter format exclusively. - const pkgPath = join(dir, "package.json"); - const claudePluginPath = join(dir, ".claude-plugin", "plugin.json"); - const hasPkg = existsSync(pkgPath); - const hasClaudePlugin = existsSync(claudePluginPath); - if (!hasPkg && !hasClaudePlugin) return; - - const resolvedDir = resolve(dir); - if (seen.has(resolvedDir)) return; - seen.add(resolvedDir); - - let packageName: string | undefined; - if (hasPkg) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string }; - packageName = pkg.name; - } catch { - packageName = undefined; - } - } else if (hasClaudePlugin) { - try { - const manifest = JSON.parse(readFileSync(claudePluginPath, "utf8")) as { name?: string }; - packageName = manifest.name; - } catch { - packageName = undefined; - } - } - - results.push({ - type: "plugin", - name: packageName || basename(dir), - packageName, - path: resolvedDir, - root, - sourceLabel: sourceLabel(root), - }); - }, 4); - } - - return results.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); -} - -async function chooseMany( - ctx: ExtensionCommandContext, - title: string, - candidates: T[], -): Promise { - if (candidates.length === 0) return []; - - const mode = await ctx.ui.select(`${title} (${candidates.length} found)`, [ - "Import all discovered", - "Select individually", - "Cancel", - ]); - - if (!mode || mode === "Cancel") return []; - if (mode === "Import all discovered") return candidates; - - const remaining = [...candidates]; - const selected: T[] = []; - while (remaining.length > 0) { - const options = [ - ...remaining.map((item) => `${item.name} — ${item.sourceLabel} — ${relative(item.root, item.path) || "."}`), - "Done selecting", - ]; - const picked = await ctx.ui.select(`${title}: choose an item`, options); - if (!picked || picked === "Done selecting") break; - const pickedStr = Array.isArray(picked) ? picked[0] : picked; - if (!pickedStr) break; - const idx = options.indexOf(pickedStr); - if (idx < 0 || idx >= remaining.length) break; - selected.push(remaining[idx]!); - remaining.splice(idx, 1); - } - return selected; -} - -function mergeStringList(existing: unknown, additions: string[]): string[] { - const list = Array.isArray(existing) ? existing.filter((v): v is string => typeof v === "string") : []; - const seen = new Set(list); - for (const item of additions) { - if (!seen.has(item)) { - list.push(item); - seen.add(item); - } - } - return list; -} - -function mergePackageSources(existing: unknown, additions: string[]): Array { - const current = Array.isArray(existing) - ? existing.filter((v): v is string | { source: string } => typeof v === "string" || (typeof v === "object" && v !== null && typeof (v as { source?: unknown }).source === "string")) - : []; - - const seen = new Set(current.map((entry) => typeof entry === "string" ? entry : entry.source)); - const merged = [...current]; - for (const add of additions) { - if (!seen.has(add)) { - merged.push(add); - seen.add(add); - } - } - return merged; -} - -// ============================================================================ -// Marketplace PluginImporter Integration (T02) -// ============================================================================ - -/** - * Component candidate from marketplace discovery. - * Extends NamespacedComponent with UI-friendly fields. - */ -interface MarketplaceComponentCandidate { - component: NamespacedComponent; - displayName: string; - pluginName: string; -} - -/** - * Format a component for display in selection UI. - */ -function formatComponentForSelection(comp: NamespacedComponent): string { - const typeLabel = comp.type === 'skill' ? '🔧' : '🤖'; - const nsLabel = comp.namespace ? `${comp.namespace}:` : ''; - return `${typeLabel} ${nsLabel}${comp.name}`; -} - -/** - * Present marketplace components for user selection, grouped by plugin. - * Returns the selected components for import. - */ -async function selectMarketplaceComponents( - ctx: ExtensionCommandContext, - importer: PluginImporter, - scope: "global" | "project" -): Promise { - const plugins = importer.getDiscoveredPlugins(); - - if (plugins.length === 0) { - ctx.ui.notify("No plugins discovered in marketplace.", "info"); - return []; - } - - // Build component candidates grouped by plugin - const allComponents: MarketplaceComponentCandidate[] = []; - for (const plugin of plugins) { - const components = importer.selectComponents(c => c.namespace === plugin.canonicalName); - for (const comp of components) { - allComponents.push({ - component: comp, - displayName: formatComponentForSelection(comp), - pluginName: plugin.canonicalName, - }); - } - } - - if (allComponents.length === 0) { - ctx.ui.notify("No components (skills/agents) found in marketplace plugins.", "info"); - return []; - } - - // Ask user for selection mode - const mode = await ctx.ui.select( - `Marketplace components → ${scope} config (${allComponents.length} found across ${plugins.length} plugins)`, - [ - "Import all components", - "Select by plugin", - "Select individually", - "Cancel", - ] - ); - - if (!mode || mode === "Cancel") return []; - - if (mode === "Import all components") { - return allComponents.map(c => c.component); - } - - if (mode === "Select by plugin") { - // Let user select plugins, then import all their components - const pluginNames = plugins.map(p => p.canonicalName); - const selectedPluginNames: string[] = []; - - while (true) { - const remaining = pluginNames.filter(n => !selectedPluginNames.includes(n)); - if (remaining.length === 0) break; - - const options = [...remaining, "Done selecting"]; - const picked = await ctx.ui.select("Select a plugin to import all its components", options); - - if (!picked || picked === "Done selecting") break; - const pickedStr = Array.isArray(picked) ? picked[0] : picked; - if (!pickedStr) break; - selectedPluginNames.push(pickedStr); - } - - return allComponents - .filter(c => selectedPluginNames.includes(c.pluginName)) - .map(c => c.component); - } - - // Select individually - const remaining = [...allComponents]; - const selected: NamespacedComponent[] = []; - - while (remaining.length > 0) { - const options = remaining.map(c => - `${c.displayName} — ${c.pluginName}` - ); - options.push("Done selecting"); - - const picked = await ctx.ui.select("Select a component to import", options); - if (!picked || picked === "Done selecting") break; - const pickedStr = Array.isArray(picked) ? picked[0] : picked; - if (!pickedStr) break; - - const idx = options.indexOf(pickedStr); - if (idx < 0 || idx >= remaining.length) break; - - selected.push(remaining[idx]!.component); - remaining.splice(idx, 1); - } - - return selected; -} - -/** - * Format diagnostics for display to user. - * Returns a human-readable summary string. - */ -function formatDiagnosticsForUser( - diagnostics: Array<{ severity: string; class: string; remediation: string; involvedCanonicalNames: string[] }> -): string { - const lines: string[] = []; - - const errors = diagnostics.filter(d => d.severity === 'error'); - const warnings = diagnostics.filter(d => d.severity === 'warning'); - - if (errors.length > 0) { - lines.push(`❌ ${errors.length} error(s) blocking import:`); - for (const err of errors) { - lines.push(` - ${err.class}: ${err.involvedCanonicalNames.join(', ')}`); - lines.push(` ${err.remediation}`); - } - } - - if (warnings.length > 0) { - lines.push(`⚠️ ${warnings.length} warning(s):`); - for (const warn of warnings) { - lines.push(` - ${warn.class}: ${warn.involvedCanonicalNames.join(', ')}`); - } - } - - return lines.join('\n'); -} - -/** - * Persist import manifest entries to settings. - * Maps manifest entries to the appropriate settings format. - */ -function persistManifestToSettings( - manifestEntries: ImportManifestEntry[], - settingsManager: SettingsManager, - scope: "global" | "project" -): void { - // Group entries by namespace for organized persistence - const skillPaths = manifestEntries - .filter(e => e.type === 'skill') - .map(e => e.filePath); - - const agentPaths = manifestEntries - .filter(e => e.type === 'agent') - .map(e => e.filePath); - - // For marketplace plugins, we also want to store plugin-level metadata - // Currently this adds component paths to skills/agents lists - // Future enhancement: store canonical names with metadata - - if (skillPaths.length > 0) { - if (scope === "project") { - settingsManager.setProjectSkillPaths( - mergeStringList(settingsManager.getProjectSettings().skills, skillPaths) - ); - } else { - settingsManager.setSkillPaths( - mergeStringList(settingsManager.getGlobalSettings().skills, skillPaths) - ); - } - } - - // Do not persist imported marketplace agents into settings.packages. - // Claude plugin agent directories contain markdown agent definitions, not loadable Pi - // extension packages. Writing `.../agents` paths into packages makes startup treat - // them as extension roots and produces module-load errors. - // - // For now, marketplace agents remain discoverable via the import manifest and - // canonical metadata, but are not persisted into package sources. -} - - -export async function runClaudeImportFlow( - ctx: ExtensionCommandContext, - scope: "global" | "project", - readPrefs: () => Record, - writePrefs: (prefs: Record) => Promise, -): Promise { - const cwd = process.cwd(); - const settingsManager = SettingsManager.create(cwd, getAgentDir()); - const { skillRoots, pluginRoots } = getClaudeSearchRoots(cwd); - - // Categorize plugin roots into marketplaces vs flat paths - const { marketplaces, flat } = categorizePluginRoots(pluginRoots); - - // Determine import mode - const assetChoice = await ctx.ui.select("Import Claude assets into SF/Pi config", [ - "Skills + plugins", - "Skills only", - "Plugins only", - "Cancel", - ]); - if (!assetChoice || assetChoice === "Cancel") return; - - const importSkills = assetChoice !== "Plugins only"; - const importPlugins = assetChoice !== "Skills only"; - - // Track what we're importing - let importedSkillsCount = 0; - let importedPluginsCount = 0; - let importedMarketplaceComponents = 0; - const canonicalNamesPersisted: string[] = []; - - // ========== SKILLS (legacy flat flow) ========== - if (importSkills) { - const discoveredSkills = discoverClaudeSkills(cwd); - const selectedSkills = await chooseMany(ctx, `Claude skills → ${scope} preferences`, discoveredSkills); - - if (selectedSkills.length > 0) { - const prefMode = await ctx.ui.select("How should SF treat the imported skills?", [ - "Always use when relevant", - "Prefer when relevant", - "Do not modify skill preferences", - ]); - - const prefs = readPrefs(); - const skillPaths = selectedSkills.map((skill) => skill.path); - if (prefMode === "Always use when relevant") { - prefs.always_use_skills = mergeStringList(prefs.always_use_skills, skillPaths); - } else if (prefMode === "Prefer when relevant") { - prefs.prefer_skills = mergeStringList(prefs.prefer_skills, skillPaths); - } - - await writePrefs(prefs); - - if (scope === "project") { - settingsManager.setProjectSkillPaths(mergeStringList(settingsManager.getProjectSettings().skills, skillPaths)); - } else { - settingsManager.setSkillPaths(mergeStringList(settingsManager.getGlobalSettings().skills, skillPaths)); - } - - importedSkillsCount = selectedSkills.length; - } - } - - // ========== MARKETPLACE PLUGINS (new PluginImporter flow) ========== - if (importPlugins && marketplaces.length > 0) { - const marketplaceChoice = await ctx.ui.select( - `Found ${marketplaces.length} marketplace(s). Import from marketplace?`, - [ - "Yes - discover plugins and select components", - "Skip marketplaces (use legacy plugin paths only)", - "Cancel", - ] - ); - - if (marketplaceChoice === "Yes - discover plugins and select components") { - // Instantiate PluginImporter and discover - const importer = new PluginImporter(); - const discovery = importer.discover(marketplaces); - - if (discovery.summary.totalPlugins > 0) { - // Present components for selection - const selectedComponents = await selectMarketplaceComponents(ctx, importer, scope); - - if (selectedComponents.length > 0) { - // Run validation (pre-import diagnostics) - const validation = importer.validateImport(selectedComponents); - - // Show diagnostics - if (validation.diagnostics.length > 0) { - const diagMessage = formatDiagnosticsForUser(validation.diagnostics); - ctx.ui.notify(diagMessage, validation.canProceed ? "warning" : "error"); - - // Block if errors exist - if (!validation.canProceed) { - ctx.ui.notify( - "Import blocked due to canonical name conflicts. Please resolve the errors above.", - "error" - ); - return; - } - - // Warn but allow proceed for warnings - const proceed = await ctx.ui.select( - "Warnings detected. Continue with import?", - ["Yes, continue", "Cancel"] - ); - if (proceed !== "Yes, continue") { - return; - } - } - - // Generate manifest and persist - const manifest = importer.getImportManifest(selectedComponents); - persistManifestToSettings(manifest.entries, settingsManager, scope); - - importedMarketplaceComponents = selectedComponents.length; - canonicalNamesPersisted.push(...manifest.entries.map(e => e.canonicalName)); - } - } else { - ctx.ui.notify(`No plugins discovered in ${marketplaces.length} marketplace(s).`, "info"); - } - } - } - - // ========== FLAT PLUGIN PATHS (legacy flow) ========== - if (importPlugins && flat.length > 0) { - // Use legacy discovery for non-marketplace paths - const discoveredPlugins: ClaudePluginCandidate[] = []; - const seen = new Set(); - - for (const root of flat) { - walkDirs(root, (dir) => { - const pkgPath = join(dir, "package.json"); - if (!existsSync(pkgPath)) return; - const resolvedDir = resolve(dir); - if (seen.has(resolvedDir)) return; - seen.add(resolvedDir); - let packageName: string | undefined; - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string }; - packageName = pkg.name; - } catch { - packageName = undefined; - } - discoveredPlugins.push({ - type: "plugin", - name: packageName || basename(dir), - packageName, - path: resolvedDir, - root, - sourceLabel: sourceLabel(root), - }); - }, 4); - } - - const sortedPlugins = discoveredPlugins.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); - const selectedPlugins = await chooseMany(ctx, `Claude plugins/packages → ${scope} Pi settings`, sortedPlugins); - - if (selectedPlugins.length > 0) { - const pluginPaths = selectedPlugins.map((plugin) => plugin.path); - if (scope === "project") { - settingsManager.setProjectPackages(mergePackageSources(settingsManager.getProjectSettings().packages, pluginPaths)); - } else { - settingsManager.setPackages(mergePackageSources(settingsManager.getGlobalSettings().packages, pluginPaths)); - } - importedPluginsCount = selectedPlugins.length; - } - } - - // ========== FINAL SUMMARY ========== - if (importedSkillsCount === 0 && importedPluginsCount === 0 && importedMarketplaceComponents === 0) { - ctx.ui.notify("Claude import cancelled or nothing selected.", "info"); - return; - } - - await ctx.waitForIdle(); - await ctx.reload(); - - const lines = [ - `Imported Claude assets into ${scope} config:`, - `- Skills (flat): ${importedSkillsCount}`, - `- Plugins (flat paths): ${importedPluginsCount}`, - `- Marketplace components: ${importedMarketplaceComponents}`, - ]; - if (importedSkillsCount > 0) { - lines.push(`- Skill paths added to Pi settings (${scope}) for availability`); - lines.push(`- Skill refs added to SF preferences (${scope}) when selected`); - } - if (importedPluginsCount > 0) { - lines.push(`- Plugin/package paths added to Pi settings (${scope}) packages`); - } - if (importedMarketplaceComponents > 0) { - lines.push(`- Canonical names preserved: ${canonicalNamesPersisted.length} entries`); - if (canonicalNamesPersisted.length <= 10) { - lines.push(` Names: ${canonicalNamesPersisted.join(', ')}`); - } - } - ctx.ui.notify(lines.join("\n"), "info"); -} diff --git a/src/resources/extensions/gsd/codebase-generator.ts b/src/resources/extensions/gsd/codebase-generator.ts deleted file mode 100644 index 34265c746..000000000 --- a/src/resources/extensions/gsd/codebase-generator.ts +++ /dev/null @@ -1,625 +0,0 @@ -/** - * SF Codebase Map Generator - * - * Produces .gsd/CODEBASE.md — a structural table of contents for the project. - * Gives fresh agent contexts instant orientation without filesystem exploration. - * - * Generation: walk `git ls-files`, group by directory, output with descriptions. - * Maintenance: agent updates descriptions as it works; incremental update preserves them. - */ - -import { createHash } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, dirname, extname } from "node:path"; - -import { execSync } from "node:child_process"; -import { gsdRoot } from "./paths.js"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface CodebaseMapOptions { - excludePatterns?: string[]; - maxFiles?: number; - collapseThreshold?: number; -} - -export interface CodebaseMapMetadata { - generatedAt: string; - fingerprint: string; - fileCount: number; - truncated: boolean; -} - -export interface EnsureCodebaseMapOptions { - ttlMs?: number; - maxAgeMs?: number; - force?: boolean; -} - -export interface EnsureCodebaseMapResult { - status: "generated" | "updated" | "fresh" | "empty"; - fileCount: number; - truncated: boolean; - generatedAt: string | null; - fingerprint: string | null; - reason?: string; -} - -interface FileEntry { - path: string; - description: string; -} - -interface DirectoryGroup { - path: string; - files: FileEntry[]; - collapsed: boolean; -} - -interface ResolvedCodebaseMapOptions { - excludes: string[]; - maxFiles: number; - collapseThreshold: number; - optionSignature: string; -} - -interface EnumeratedFiles { - files: string[]; - truncated: boolean; -} - -// ─── Defaults ──────────────────────────────────────────────────────────────── - -const DEFAULT_EXCLUDES = [ - // ── AI / tooling meta ── - ".agents/", - ".gsd/", - ".planning/", - ".plans/", - ".claude/", - ".cursor/", - ".bg-shell/", - - // ── Editor / IDE ── - ".vscode/", - ".idea/", - - // ── VCS ── - ".git/", - - // ── Dependencies & build artifacts ── - "node_modules/", - "dist/", - "build/", - ".next/", - "coverage/", - "__pycache__/", - ".venv/", - "venv/", - "vendor/", - "target/", - - // ── Misc ── - ".cache/", - "tmp/", -]; - -const DEFAULT_MAX_FILES = 500; -const DEFAULT_COLLAPSE_THRESHOLD = 20; -const DEFAULT_REFRESH_TTL_MS = 30_000; -const DEFAULT_MAX_AGE_MS = 15 * 60_000; -const CODEBASE_METADATA_PREFIX = " comment blocks to preserve - * descriptions for files in collapsed directories across incremental updates. - */ -export function parseCodebaseMap(content: string): Map { - const descriptions = new Map(); - let inCollapsedBlock = false; - - for (const line of content.split("\n")) { - // Track collapsed-description comment blocks - if (line.trimStart().startsWith("")) { - inCollapsedBlock = false; - continue; - } - - // Match: - `path/to/file.ts` — Description here - const match = line.match(/^- `(.+?)` — (.+)$/); - if (match) { - descriptions.set(match[1], match[2]); - continue; - } - - // Match: - `path/to/file.ts` (no description) — only outside collapsed blocks - if (!inCollapsedBlock) { - const bareMatch = line.match(/^- `(.+?)`\s*$/); - if (bareMatch) { - descriptions.set(bareMatch[1], ""); - } - } - } - return descriptions; -} - -export function parseCodebaseMapMetadata(content: string): CodebaseMapMetadata | null { - const metaLine = content - .split("\n") - .find((line) => line.trimStart().startsWith(CODEBASE_METADATA_PREFIX)); - if (!metaLine) return null; - - const trimmed = metaLine.trim(); - const jsonStart = CODEBASE_METADATA_PREFIX.length; - const jsonEnd = trimmed.lastIndexOf(" -->"); - if (jsonEnd <= jsonStart) return null; - - try { - const parsed = JSON.parse(trimmed.slice(jsonStart, jsonEnd)); - if ( - typeof parsed?.generatedAt === "string" - && typeof parsed?.fingerprint === "string" - && typeof parsed?.fileCount === "number" - && typeof parsed?.truncated === "boolean" - ) { - return parsed as CodebaseMapMetadata; - } - } catch { - // Ignore malformed metadata and treat the map as stale. - } - return null; -} - -// ─── File Enumeration ──────────────────────────────────────────────────────── - -function shouldExclude(filePath: string, excludes: string[]): boolean { - for (const pattern of excludes) { - if (pattern.endsWith("/")) { - if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`)) return true; - } else if (filePath === pattern || filePath.endsWith(`/${pattern}`)) { - return true; - } - } - // Skip binary/lock files - const ext = extname(filePath).toLowerCase(); - if ([".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".svg"].includes(ext)) { - return true; - } - return false; -} - -function lsFiles(basePath: string): string[] { - try { - // stdio: "pipe" captures stderr into the thrown Error instead of - // inheriting it to the parent. Without it, running gsd from a non-repo - // cwd (e.g. `$HOME`) leaks a "fatal: not a git repository" line to the - // user's terminal before the catch silently falls through to []. - const result = execSync("git ls-files", { - cwd: basePath, - encoding: "utf-8", - timeout: 10000, - stdio: ["ignore", "pipe", "pipe"], - }); - return result.split("\n").filter(Boolean); - } catch { - return []; - } -} - -/** - * Enumerate tracked files, applying exclusions and the maxFiles cap. - * Returns both the file list and whether truncation occurred. - */ -function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): { files: string[]; truncated: boolean } { - const allFiles = lsFiles(basePath); - const filtered = allFiles.filter((f) => !shouldExclude(f, excludes)); - const truncated = filtered.length > maxFiles; - return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated }; -} - -function resolveGeneratorOptions(options?: CodebaseMapOptions): ResolvedCodebaseMapOptions { - const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; - const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; - const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; - return { - excludes, - maxFiles, - collapseThreshold, - optionSignature: JSON.stringify({ - excludes, - maxFiles, - collapseThreshold, - }), - }; -} - -function computeCodebaseFingerprint( - files: string[], - resolved: ResolvedCodebaseMapOptions, - truncated: boolean, -): string { - return createHash("sha1") - .update(JSON.stringify({ - files, - truncated, - optionSignature: resolved.optionSignature, - })) - .digest("hex"); -} - -// ─── Grouping ──────────────────────────────────────────────────────────────── - -function groupByDirectory( - files: string[], - descriptions: Map, - collapseThreshold: number, -): DirectoryGroup[] { - const dirMap = new Map(); - - for (const file of files) { - const dir = dirname(file); - const dirKey = dir === "." ? "" : dir; - if (!dirMap.has(dirKey)) { - dirMap.set(dirKey, []); - } - dirMap.get(dirKey)!.push({ - path: file, - description: descriptions.get(file) ?? "", - }); - } - - const groups: DirectoryGroup[] = []; - const sortedDirs = [...dirMap.keys()].sort(); - - for (const dir of sortedDirs) { - const dirFiles = dirMap.get(dir)!; - dirFiles.sort((a, b) => a.path.localeCompare(b.path)); - - groups.push({ - path: dir, - files: dirFiles, - collapsed: dirFiles.length > collapseThreshold, - }); - } - - return groups; -} - -// ─── Rendering ─────────────────────────────────────────────────────────────── - -function renderCodebaseMap( - groups: DirectoryGroup[], - totalFiles: number, - truncated: boolean, - metadata: CodebaseMapMetadata, -): string { - const lines: string[] = []; - const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0); - - lines.push("# Codebase Map"); - lines.push(""); - lines.push(`Generated: ${metadata.generatedAt} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`); - lines.push(`${CODEBASE_METADATA_PREFIX}${JSON.stringify(metadata)} -->`); - if (truncated) { - lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`); - } - lines.push(""); - - for (const group of groups) { - const heading = group.path || "(root)"; - lines.push(`### ${heading}/`); - - if (group.collapsed) { - // Summarize collapsed directories - const extensions = new Map(); - for (const f of group.files) { - const ext = extname(f.path) || "(no ext)"; - extensions.set(ext, (extensions.get(ext) ?? 0) + 1); - } - const extSummary = [...extensions.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([ext, count]) => `${count} ${ext}`) - .join(", "); - lines.push(`- *(${group.files.length} files: ${extSummary})*`); - - // Preserve any existing descriptions in a hidden comment block so - // incremental updates can recover them via parseCodebaseMap. - const descLines = group.files - .filter((f) => f.description) - .map((f) => `- \`${f.path}\` — ${f.description}`); - if (descLines.length > 0) { - lines.push(""); - } - } else { - for (const file of group.files) { - if (file.description) { - lines.push(`- \`${file.path}\` — ${file.description}`); - } else { - lines.push(`- \`${file.path}\``); - } - } - } - lines.push(""); - } - - return lines.join("\n"); -} - -function buildCodebaseMap( - basePath: string, - resolved: ResolvedCodebaseMapOptions, - existingDescriptions?: Map, - enumerated?: EnumeratedFiles, -): { - content: string; - fileCount: number; - truncated: boolean; - files: string[]; - fingerprint: string; - generatedAt: string; -} { - const listed = enumerated ?? enumerateFiles(basePath, resolved.excludes, resolved.maxFiles); - const descriptions = existingDescriptions ?? new Map(); - const groups = groupByDirectory(listed.files, descriptions, resolved.collapseThreshold); - const generatedAt = new Date().toISOString().split(".")[0] + "Z"; - const metadata: CodebaseMapMetadata = { - generatedAt, - fingerprint: computeCodebaseFingerprint(listed.files, resolved, listed.truncated), - fileCount: listed.files.length, - truncated: listed.truncated, - }; - const content = renderCodebaseMap(groups, listed.files.length, listed.truncated, metadata); - - return { - content, - fileCount: listed.files.length, - truncated: listed.truncated, - files: listed.files, - fingerprint: metadata.fingerprint, - generatedAt, - }; -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -/** - * Generate a fresh CODEBASE.md from scratch. - * Preserves existing descriptions if `existingDescriptions` is provided. - */ -export function generateCodebaseMap( - basePath: string, - options?: CodebaseMapOptions, - existingDescriptions?: Map, -): { content: string; fileCount: number; truncated: boolean; files: string[]; fingerprint: string; generatedAt: string } { - const resolved = resolveGeneratorOptions(options); - return buildCodebaseMap(basePath, resolved, existingDescriptions); -} - -/** - * Incremental update: re-scan files, preserve existing descriptions, - * add new files, remove deleted files. - */ -export function updateCodebaseMap( - basePath: string, - options?: CodebaseMapOptions, -): { - content: string; - added: number; - removed: number; - unchanged: number; - fileCount: number; - truncated: boolean; - fingerprint: string; - generatedAt: string; -} { - const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); - const resolved = resolveGeneratorOptions(options); - - // Load existing descriptions - let existingDescriptions = new Map(); - if (existsSync(codebasePath)) { - const existing = readFileSync(codebasePath, "utf-8"); - existingDescriptions = parseCodebaseMap(existing); - } - - const existingFiles = new Set(existingDescriptions.keys()); - - // Generate new map preserving descriptions — reuse the returned file list - // to avoid a second enumeration (prevents race between content and stats). - const result = buildCodebaseMap(basePath, resolved, existingDescriptions); - const currentSet = new Set(result.files); - - // Count changes - let added = 0; - let removed = 0; - - for (const f of result.files) { - if (!existingFiles.has(f)) added++; - } - for (const f of existingFiles) { - if (!currentSet.has(f)) removed++; - } - - return { - content: result.content, - added, - removed, - unchanged: result.files.length - added, - fileCount: result.fileCount, - truncated: result.truncated, - fingerprint: result.fingerprint, - generatedAt: result.generatedAt, - }; -} - -function clearFreshnessCache(basePath: string): void { - for (const key of freshnessCache.keys()) { - if (key === basePath || key.startsWith(`${basePath}::`)) { - freshnessCache.delete(key); - } - } -} - -export function ensureCodebaseMapFresh( - basePath: string, - options?: CodebaseMapOptions, - ensureOptions?: EnsureCodebaseMapOptions, -): EnsureCodebaseMapResult { - const resolved = resolveGeneratorOptions(options); - const cacheKey = `${basePath}::${resolved.optionSignature}`; - const ttlMs = ensureOptions?.ttlMs ?? DEFAULT_REFRESH_TTL_MS; - const maxAgeMs = ensureOptions?.maxAgeMs ?? DEFAULT_MAX_AGE_MS; - const force = ensureOptions?.force === true; - const now = Date.now(); - - if (!force && ttlMs > 0) { - const cached = freshnessCache.get(cacheKey); - if (cached && now - cached.checkedAt < ttlMs) { - return cached.result; - } - } - - const existing = readCodebaseMap(basePath); - const listed = enumerateFiles(basePath, resolved.excludes, resolved.maxFiles); - const fingerprint = computeCodebaseFingerprint(listed.files, resolved, listed.truncated); - - const cacheAndReturn = (result: EnsureCodebaseMapResult): EnsureCodebaseMapResult => { - freshnessCache.set(cacheKey, { checkedAt: now, result }); - return result; - }; - - if (!existing) { - const generated = buildCodebaseMap(basePath, resolved, undefined, listed); - if (generated.fileCount > 0) { - writeCodebaseMap(basePath, generated.content); - return cacheAndReturn({ - status: "generated", - fileCount: generated.fileCount, - truncated: generated.truncated, - generatedAt: generated.generatedAt, - fingerprint: generated.fingerprint, - reason: "missing", - }); - } - return cacheAndReturn({ - status: "empty", - fileCount: 0, - truncated: false, - generatedAt: null, - fingerprint, - reason: "no-tracked-files", - }); - } - - const metadata = parseCodebaseMapMetadata(existing); - const existingDescriptions = parseCodebaseMap(existing); - const ageMs = metadata ? now - Date.parse(metadata.generatedAt) : Number.POSITIVE_INFINITY; - const staleReason = - !metadata ? "missing-metadata" - : metadata.fingerprint !== fingerprint ? "files-changed" - : metadata.fileCount !== listed.files.length ? "file-count-changed" - : metadata.truncated !== listed.truncated ? "truncation-changed" - : maxAgeMs > 0 && Number.isFinite(ageMs) && ageMs > maxAgeMs ? "expired" - : undefined; - - if (!staleReason) { - return cacheAndReturn({ - status: "fresh", - fileCount: metadata?.fileCount ?? listed.files.length, - truncated: metadata?.truncated ?? listed.truncated, - generatedAt: metadata?.generatedAt ?? null, - fingerprint: metadata?.fingerprint ?? fingerprint, - }); - } - - const updated = buildCodebaseMap(basePath, resolved, existingDescriptions, listed); - if (updated.fileCount > 0) { - writeCodebaseMap(basePath, updated.content); - return cacheAndReturn({ - status: "updated", - fileCount: updated.fileCount, - truncated: updated.truncated, - generatedAt: updated.generatedAt, - fingerprint: updated.fingerprint, - reason: staleReason, - }); - } - - return cacheAndReturn({ - status: "empty", - fileCount: 0, - truncated: false, - generatedAt: null, - fingerprint, - reason: staleReason, - }); -} - -/** - * Write CODEBASE.md to .gsd/ directory. - */ -export function writeCodebaseMap(basePath: string, content: string): string { - const root = gsdRoot(basePath); - mkdirSync(root, { recursive: true }); - const outPath = join(root, "CODEBASE.md"); - writeFileSync(outPath, content, "utf-8"); - clearFreshnessCache(basePath); - return outPath; -} - -/** - * Read existing CODEBASE.md, or return null if it doesn't exist. - */ -export function readCodebaseMap(basePath: string): string | null { - const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); - if (!existsSync(codebasePath)) return null; - try { - return readFileSync(codebasePath, "utf-8"); - } catch { - return null; - } -} - -/** - * Get stats about the codebase map. - */ -export function getCodebaseMapStats(basePath: string): { - exists: boolean; - fileCount: number; - describedCount: number; - undescribedCount: number; - generatedAt: string | null; -} { - const content = readCodebaseMap(basePath); - if (!content) { - return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null }; - } - - // Parse total file count from the header line (accurate even for collapsed dirs) - const fileCountMatch = content.match(/Files:\s*(\d+)/); - const totalFiles = fileCountMatch ? parseInt(fileCountMatch[1], 10) : 0; - - // Use parseCodebaseMap to count described files (includes collapsed-description blocks) - const descriptions = parseCodebaseMap(content); - const described = [...descriptions.values()].filter((d) => d.length > 0).length; - const dateMatch = content.match(/Generated: (\S+)/); - - return { - exists: true, - fileCount: totalFiles, - describedCount: described, - undescribedCount: totalFiles - described, - generatedAt: dateMatch?.[1] ?? null, - }; -} diff --git a/src/resources/extensions/gsd/collision-diagnostics.ts b/src/resources/extensions/gsd/collision-diagnostics.ts deleted file mode 100644 index 09ec44c12..000000000 --- a/src/resources/extensions/gsd/collision-diagnostics.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Collision Diagnostics Module - * - * Bridges NamespacedRegistry collision data and NamespacedResolver ambiguous - * resolution into a classified diagnostic taxonomy. Provides two functions: - * - analyzeCollisions: Scans registry and resolver state to produce classified diagnostics - * - doctorReport: Formats diagnostics into human-readable output with severity and remediation - * - * This module implements R010 (collision reporting) and R011 (doctor advice) for the - * namespaced component system. - */ - -import type { NamespacedRegistry, RegistryDiagnostic } from './namespaced-registry.js'; -import type { NamespacedResolver, ResolutionResult } from './namespaced-resolver.js'; - -// ============================================================================ -// Type Definitions -// ============================================================================ - -/** - * Classification of collision type. - * - canonical-conflict: Two plugins registered the same canonical name (hard error) - * - shorthand-overlap: Same bare name exists in multiple namespaces (ambiguity) - * - alias-conflict: Alias shadows a canonical name or bare component name - */ -export type CollisionClass = 'canonical-conflict' | 'shorthand-overlap' | 'alias-conflict'; - -/** - * Severity level for diagnostics. - * - error: Hard collision that prevents correct resolution - * - warning: Ambiguity that may cause surprising behavior - */ -export type DiagnosticSeverity = 'error' | 'warning'; - -/** - * A classified diagnostic with full context for remediation. - */ -export interface ClassifiedDiagnostic { - /** The collision classification */ - class: CollisionClass; - - /** Severity level */ - severity: DiagnosticSeverity; - - /** All canonical names involved in the collision */ - involvedCanonicalNames: string[]; - - /** File paths to the conflicting components */ - filePaths: string[]; - - /** Human-readable remediation advice */ - remediation: string; - - /** Optional: the bare name causing ambiguity (shorthand-overlap only) */ - ambiguousBareName?: string; - - /** Optional: the alias string (alias-conflict only) */ - alias?: string; - - /** Optional: the canonical name the alias points to (alias-conflict only) */ - aliasTarget?: string; - - /** Optional: type of alias conflict */ - aliasConflictType?: 'shadows-canonical' | 'shadows-bare-name'; -} - -/** - * Doctor report with summary statistics and formatted entries. - */ -export interface DoctorReport { - /** Summary counts by class */ - summary: { - /** Total diagnostics */ - total: number; - /** Canonical conflicts (errors) */ - canonicalConflicts: number; - /** Shorthand overlaps (warnings) */ - shorthandOverlaps: number; - /** Alias conflicts (warnings) */ - aliasConflicts: number; - }; - - /** Formatted report entries */ - entries: string[]; -} - -// ============================================================================ -// Implementation -// ============================================================================ - -/** - * Analyze a registry and resolver to produce classified diagnostics. - * - * This function: - * 1. Reads registry.getDiagnostics() for canonical conflicts (→ error severity) - * 2. Groups registry.getAll() by bare component.name - * 3. For groups with 2+ entries, calls resolver.resolve(bareName) to confirm ambiguity - * 4. Produces warning diagnostics for ambiguous shorthand resolution - * - * @param registry - The namespaced registry to analyze - * @param resolver - The resolver to test ambiguity - * @returns Array of classified diagnostics - */ -export function analyzeCollisions( - registry: NamespacedRegistry, - resolver: NamespacedResolver -): ClassifiedDiagnostic[] { - const diagnostics: ClassifiedDiagnostic[] = []; - - // Step 1: Process canonical conflicts from registry diagnostics - const registryDiagnostics = registry.getDiagnostics(); - for (const diag of registryDiagnostics) { - if (diag.type === 'collision') { - diagnostics.push({ - class: 'canonical-conflict', - severity: 'error', - involvedCanonicalNames: [diag.collision.canonicalName], - filePaths: [diag.collision.winnerPath, diag.collision.loserPath], - remediation: `Canonical name "${diag.collision.canonicalName}" registered multiple times. ` + - `The first registration (${diag.collision.winnerSource ?? 'unknown source'}) ` + - `took precedence over subsequent registration (${diag.collision.loserSource ?? 'unknown source'}). ` + - `Rename one of the conflicting components to resolve.`, - }); - } - } - - // Step 2: Find shorthand overlaps by grouping components by bare name - const components = registry.getAll(); - const byBareName = new Map(); - - for (const component of components) { - const bareName = component.name; - if (!byBareName.has(bareName)) { - byBareName.set(bareName, []); - } - byBareName.get(bareName)!.push(component); - } - - // Step 3: For groups with 2+ entries, check if resolver confirms ambiguity - for (const [bareName, candidates] of byBareName) { - if (candidates.length >= 2) { - // Use resolver to confirm ambiguity - const result = resolver.resolve(bareName); - - if (result.resolution === 'ambiguous') { - // This is a shorthand overlap - const canonicalNames = candidates.map(c => c.canonicalName); - const filePaths = candidates.map(c => c.filePath); - - diagnostics.push({ - class: 'shorthand-overlap', - severity: 'warning', - involvedCanonicalNames: canonicalNames, - filePaths, - remediation: formatShorthandRemediation(bareName, canonicalNames), - ambiguousBareName: bareName, - }); - } - // If resolution is 'shorthand' or 'local-first', the overlap is resolved - // unambiguously by the resolver, so we don't warn - } - } - - // Step 4: Check for alias conflicts - const aliases = registry.getAliases(); - const canonicalNamesSet = new Set(components.map(c => c.canonicalName)); - - for (const [alias, targetCanonical] of aliases) { - // Check if alias shadows a canonical name - // (This can happen if a component was registered AFTER the alias was created) - if (canonicalNamesSet.has(alias)) { - const shadowedComponent = components.find(c => c.canonicalName === alias); - const aliasedComponent = components.find(c => c.canonicalName === targetCanonical); - - diagnostics.push({ - class: 'alias-conflict', - severity: 'warning', - involvedCanonicalNames: [alias, targetCanonical], - filePaths: [ - shadowedComponent?.filePath ?? '', - aliasedComponent?.filePath ?? '', - ], - remediation: formatAliasShadowsCanonicalRemediation(alias, targetCanonical), - alias, - aliasTarget: targetCanonical, - aliasConflictType: 'shadows-canonical', - }); - continue; // Skip further checks for this alias - } - - // Check if alias shadows a bare name (matches component.name in any namespace) - const matchingBareNames = components.filter(c => c.name === alias); - if (matchingBareNames.length > 0) { - const filePaths = matchingBareNames.map(c => c.filePath); - const aliasedComponent = components.find(c => c.canonicalName === targetCanonical); - if (aliasedComponent) filePaths.push(aliasedComponent.filePath); - - diagnostics.push({ - class: 'alias-conflict', - severity: 'warning', - involvedCanonicalNames: [targetCanonical, ...matchingBareNames.map(c => c.canonicalName)], - filePaths, - remediation: formatAliasShadowsBareNameRemediation(alias, targetCanonical, matchingBareNames.map(c => c.canonicalName)), - alias, - aliasTarget: targetCanonical, - aliasConflictType: 'shadows-bare-name', - }); - } - } - - return diagnostics; -} - -/** - * Format remediation advice for shorthand overlap. - * - * @param bareName - The ambiguous bare name - * @param canonicalNames - All canonical names that match - * @returns Human-readable remediation message - */ -function formatShorthandRemediation(bareName: string, canonicalNames: string[]): string { - const suggestions = canonicalNames - .map(cn => `\`${cn}\``) - .join(', '); - - return `Bare name "${bareName}" is ambiguous across ${canonicalNames.length} namespaces. ` + - `Use a canonical name (${suggestions}) to avoid ambiguity.`; -} - -/** - * Format remediation advice for alias shadowing a canonical name. - * - * @param alias - The alias that shadows a canonical name - * @param targetCanonical - The canonical name the alias points to - * @returns Human-readable remediation message - */ -function formatAliasShadowsCanonicalRemediation(alias: string, targetCanonical: string): string { - return `Alias "${alias}" shadows an existing canonical name. ` + - `The alias points to "${targetCanonical}", but resolving "${alias}" will now match the component, not the alias. ` + - `Consider rename or remove the alias to avoid confusion.`; -} - -/** - * Format remediation advice for alias shadowing a bare name. - * - * @param alias - The alias that shadows bare names - * @param targetCanonical - The canonical name the alias points to - * @param shadowedCanonicals - The canonical names whose bare names are shadowed - * @returns Human-readable remediation message - */ -function formatAliasShadowsBareNameRemediation( - alias: string, - targetCanonical: string, - shadowedCanonicals: string[] -): string { - const shadowed = shadowedCanonicals.map(cn => `\`${cn}\``).join(', '); - return `Alias "${alias}" shadows ${shadowedCanonicals.length} component(s) with the same bare name (${shadowed}). ` + - `Resolving "${alias}" will use the alias (pointing to "${targetCanonical}"), not shorthand resolution. ` + - `Use canonical names to be explicit, or rename the alias if this is unintended.`; -} - -/** - * Format diagnostics into a human-readable doctor report. - * - * Each diagnostic is formatted with: - * - Severity icon (❌ error / ⚠️ warning) - * - Description of the issue - * - Involved file paths - * - Remediation advice - * - * @param diagnostics - Array of classified diagnostics - * @returns Doctor report with summary and formatted entries - */ -export function doctorReport(diagnostics: ClassifiedDiagnostic[]): DoctorReport { - const summary = { - total: diagnostics.length, - canonicalConflicts: diagnostics.filter(d => d.class === 'canonical-conflict').length, - shorthandOverlaps: diagnostics.filter(d => d.class === 'shorthand-overlap').length, - aliasConflicts: diagnostics.filter(d => d.class === 'alias-conflict').length, - }; - - const entries = diagnostics.map(diagnostic => formatDiagnosticEntry(diagnostic)); - - return { summary, entries }; -} - -/** - * Format a single diagnostic entry for display. - * - * @param diagnostic - The diagnostic to format - * @returns Formatted string entry - */ -function formatDiagnosticEntry(diagnostic: ClassifiedDiagnostic): string { - const icon = diagnostic.severity === 'error' ? '❌' : '⚠️'; - const lines: string[] = []; - - // Header with severity and class - lines.push(`${icon} ${diagnostic.class.toUpperCase()}`); - - // Description - if (diagnostic.class === 'canonical-conflict') { - lines.push(` Canonical name conflict: ${diagnostic.involvedCanonicalNames[0]}`); - } else if (diagnostic.class === 'alias-conflict') { - if (diagnostic.aliasConflictType === 'shadows-canonical') { - lines.push(` Alias "${diagnostic.alias}" shadows canonical name (points to ${diagnostic.aliasTarget})`); - } else { - lines.push(` Alias "${diagnostic.alias}" shadows bare name (points to ${diagnostic.aliasTarget})`); - } - } else { - lines.push(` Shorthand overlap: "${diagnostic.ambiguousBareName}" matches ${diagnostic.involvedCanonicalNames.length} components`); - } - - // File paths - lines.push(' Files:'); - for (const path of diagnostic.filePaths) { - lines.push(` - ${path}`); - } - - // Remediation - lines.push(` Remediation: ${diagnostic.remediation}`); - - return lines.join('\n'); -} - -// ============================================================================ -// Exports -// ============================================================================ - -export default { - analyzeCollisions, - doctorReport, -}; diff --git a/src/resources/extensions/gsd/commands-add-tests.ts b/src/resources/extensions/gsd/commands-add-tests.ts deleted file mode 100644 index f61a40367..000000000 --- a/src/resources/extensions/gsd/commands-add-tests.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * SF Command — /gsd add-tests - * - * Generates tests for a completed slice by dispatching an LLM prompt - * with implementation context (summaries, changed files, test patterns). - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; - -import { deriveState } from "./state.js"; -import { gsdRoot, resolveSliceFile } from "./paths.js"; -import { loadPrompt } from "./prompt-loader.js"; - -function findLastCompletedSlice(basePath: string, milestoneId: string): string | null { - // Scan disk for slices that have a SUMMARY.md (indicating completion) - const slicesDir = join(gsdRoot(basePath), "milestones", milestoneId, "slices"); - if (!existsSync(slicesDir)) return null; - - try { - const entries = readdirSync(slicesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && /^S\d+$/.test(e.name)) - .sort((a, b) => b.name.localeCompare(a.name)); // reverse order — latest first - - for (const entry of entries) { - const summaryPath = join(slicesDir, entry.name, `${entry.name}-SUMMARY.md`); - if (existsSync(summaryPath)) return entry.name; - } - } catch { - // non-fatal - } - return null; -} - -function readSliceSummary(basePath: string, milestoneId: string, sliceId: string): { title: string; content: string } { - const summaryPath = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY"); - if (summaryPath && existsSync(summaryPath)) { - const content = readFileSync(summaryPath, "utf-8"); - const titleMatch = content.match(/^#\s+(.+)/m); - return { title: titleMatch?.[1] ?? sliceId, content }; - } - return { title: sliceId, content: "(no summary available)" }; -} - -function detectTestPatterns(basePath: string): string { - const patterns: string[] = []; - - // Check for common test configs - const checks = [ - { file: "jest.config.ts", name: "Jest" }, - { file: "jest.config.js", name: "Jest" }, - { file: "vitest.config.ts", name: "Vitest" }, - { file: "vitest.config.js", name: "Vitest" }, - { file: ".mocharc.yml", name: "Mocha" }, - ]; - - for (const check of checks) { - if (existsSync(join(basePath, check.file))) { - patterns.push(`Framework: ${check.name} (${check.file})`); - } - } - - // Look for existing test files to infer patterns - const testDirs = ["tests", "test", "src/__tests__", "__tests__"]; - for (const dir of testDirs) { - const fullDir = join(basePath, dir); - if (existsSync(fullDir)) { - try { - const files = readdirSync(fullDir).filter((f) => f.endsWith(".test.ts") || f.endsWith(".spec.ts") || f.endsWith(".test.js")); - if (files.length > 0) { - patterns.push(`Test directory: ${dir}/ (${files.length} test files)`); - // Read first test file for patterns - const samplePath = join(fullDir, files[0]); - const sample = readFileSync(samplePath, "utf-8").slice(0, 500); - patterns.push(`Sample pattern from ${files[0]}:\n${sample}`); - break; - } - } catch { - // non-fatal - } - } - } - - return patterns.length > 0 ? patterns.join("\n") : "No test framework detected. Use Node.js built-in test runner."; -} - -export async function handleAddTests( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise { - const basePath = process.cwd(); - const state = await deriveState(basePath); - - if (!state.activeMilestone) { - ctx.ui.notify("No active milestone.", "warning"); - return; - } - - const milestoneId = state.activeMilestone.id; - - // Determine target - const targetId = args.trim() || findLastCompletedSlice(basePath, milestoneId); - if (!targetId) { - ctx.ui.notify( - "No completed slices found. Specify a slice ID: /gsd add-tests S03", - "warning", - ); - return; - } - - // Gather context - const summary = readSliceSummary(basePath, milestoneId, targetId); - const testPatterns = detectTestPatterns(basePath); - - ctx.ui.notify(`Generating tests for ${targetId}: "${summary.title}"...`, "info"); - - try { - const prompt = loadPrompt("add-tests", { - sliceId: targetId, - sliceTitle: summary.title, - sliceSummary: summary.content, - existingTestPatterns: testPatterns, - workingDirectory: basePath, - }); - - pi.sendMessage( - { customType: "gsd-add-tests", content: prompt, display: false }, - { triggerTurn: true }, - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to dispatch test generation: ${msg}`, "error"); - } -} diff --git a/src/resources/extensions/gsd/commands-backlog.ts b/src/resources/extensions/gsd/commands-backlog.ts deleted file mode 100644 index 452f50154..000000000 --- a/src/resources/extensions/gsd/commands-backlog.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * SF Command — /gsd backlog - * - * Structured backlog management with 999.x numbering. - * Items stored in .gsd/BACKLOG.md as markdown checklist. - * Items can be promoted to active slices via add-slice. - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; - -import { gsdRoot } from "./paths.js"; - -interface BacklogItem { - id: string; - title: string; - done: boolean; - note: string; -} - -function backlogPath(basePath: string): string { - return join(gsdRoot(basePath), "BACKLOG.md"); -} - -function parseBacklog(basePath: string): BacklogItem[] { - const filePath = backlogPath(basePath); - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, "utf-8"); - const items: BacklogItem[] = []; - - for (const line of content.split("\n")) { - const match = line.match(/^- \[([ x])\] (999\.\d+) — (.+?)(?:\s*\((.+)\))?$/); - if (match) { - items.push({ - id: match[2], - title: match[3].trim(), - done: match[1] === "x", - note: match[4] ?? "", - }); - } - } - - return items; -} - -function writeBacklog(basePath: string, items: BacklogItem[]): void { - const filePath = backlogPath(basePath); - mkdirSync(dirname(filePath), { recursive: true }); - const lines = ["# Backlog\n"]; - for (const item of items) { - const check = item.done ? "x" : " "; - const note = item.note ? ` (${item.note})` : ""; - lines.push(`- [${check}] ${item.id} — ${item.title}${note}`); - } - lines.push(""); // trailing newline - writeFileSync(filePath, lines.join("\n"), "utf-8"); -} - -function nextBacklogId(items: BacklogItem[]): string { - let maxNum = 0; - for (const item of items) { - const match = item.id.match(/^999\.(\d+)$/); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) maxNum = num; - } - } - return `999.${maxNum + 1}`; -} - -async function listBacklog(basePath: string, ctx: ExtensionCommandContext): Promise { - const items = parseBacklog(basePath); - if (items.length === 0) { - ctx.ui.notify("Backlog is empty. Add items with /gsd backlog add ", "info"); - return; - } - - const lines = ["Backlog:\n"]; - for (const item of items) { - const status = item.done ? "✓" : "○"; - const note = item.note ? ` (${item.note})` : ""; - lines.push(` ${status} ${item.id} — ${item.title}${note}`); - } - const pending = items.filter((i) => !i.done).length; - lines.push(`\n${pending} pending, ${items.length - pending} promoted/done`); - ctx.ui.notify(lines.join("\n"), "info"); -} - -async function addBacklogItem(basePath: string, title: string, ctx: ExtensionCommandContext): Promise<void> { - if (!title) { - ctx.ui.notify("Usage: /gsd backlog add <title>", "warning"); - return; - } - - const items = parseBacklog(basePath); - const id = nextBacklogId(items); - const date = new Date().toISOString().slice(0, 10); - - items.push({ id, title: title.replace(/^['"]|['"]$/g, ""), done: false, note: `added ${date}` }); - writeBacklog(basePath, items); - - ctx.ui.notify(`Added ${id}: "${title}"`, "success"); -} - -async function promoteBacklogItem( - basePath: string, - itemId: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - if (!itemId) { - ctx.ui.notify("Usage: /gsd backlog promote <id>\nExample: /gsd backlog promote 999.1", "warning"); - return; - } - - const items = parseBacklog(basePath); - const item = items.find((i) => i.id === itemId); - - if (!item) { - ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning"); - return; - } - - if (item.done) { - ctx.ui.notify(`${itemId} is already promoted/done.`, "info"); - return; - } - - // Promote — currently requires single-writer engine (not yet available) - // Mark as promoted in backlog for now; slice creation will be available with the engine. - item.done = true; - item.note = `promoted ${new Date().toISOString().slice(0, 10)}`; - writeBacklog(basePath, items); - ctx.ui.notify(`Promoted ${itemId}: "${item.title}" — add it to the roadmap manually or wait for engine slice commands.`, "info"); -} - -async function removeBacklogItem(basePath: string, itemId: string, ctx: ExtensionCommandContext): Promise<void> { - if (!itemId) { - ctx.ui.notify("Usage: /gsd backlog remove <id>", "warning"); - return; - } - - const items = parseBacklog(basePath); - const idx = items.findIndex((i) => i.id === itemId); - - if (idx === -1) { - ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning"); - return; - } - - const removed = items.splice(idx, 1)[0]; - writeBacklog(basePath, items); - ctx.ui.notify(`Removed ${removed.id}: "${removed.title}"`, "success"); -} - -export async function handleBacklog( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - const basePath = process.cwd(); - const parts = args.trim().split(/\s+/); - const sub = parts[0] ?? ""; - const rest = parts.slice(1).join(" "); - - switch (sub) { - case "": - return listBacklog(basePath, ctx); - case "add": - return addBacklogItem(basePath, rest, ctx); - case "promote": - return promoteBacklogItem(basePath, rest.trim(), ctx, pi); - case "remove": - return removeBacklogItem(basePath, rest.trim(), ctx); - default: - // Treat as implicit add - return addBacklogItem(basePath, args, ctx); - } -} diff --git a/src/resources/extensions/gsd/commands-bootstrap.ts b/src/resources/extensions/gsd/commands-bootstrap.ts deleted file mode 100644 index e65e8a7da..000000000 --- a/src/resources/extensions/gsd/commands-bootstrap.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -const TOP_LEVEL_SUBCOMMANDS = [ - { cmd: "help", desc: "Categorized command reference with descriptions" }, - { cmd: "next", desc: "Explicit step mode (same as /gsd)" }, - { cmd: "auto", desc: "Autonomous mode — research, plan, execute, commit, repeat" }, - { cmd: "stop", desc: "Stop auto mode gracefully" }, - { cmd: "pause", desc: "Pause auto-mode (preserves state, /gsd auto to resume)" }, - { cmd: "status", desc: "Progress dashboard" }, - { cmd: "visualize", desc: "Open workflow visualizer" }, - { cmd: "queue", desc: "Queue and reorder future milestones" }, - { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, - { cmd: "discuss", desc: "Discuss architecture and decisions" }, - { cmd: "capture", desc: "Fire-and-forget thought capture" }, - { cmd: "changelog", desc: "Show categorized release notes" }, - { cmd: "triage", desc: "Manually trigger triage of pending captures" }, - { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, - { cmd: "history", desc: "View execution history" }, - { cmd: "undo", desc: "Revert last completed unit" }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, - { cmd: "export", desc: "Export milestone or slice results" }, - { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, - { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, - { cmd: "prefs", desc: "Manage preferences" }, - { cmd: "config", desc: "Set API keys for external tools" }, - { cmd: "keys", desc: "API key manager" }, - { cmd: "hooks", desc: "Show configured hooks" }, - { cmd: "run-hook", desc: "Manually trigger a specific hook" }, - { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, - { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, - { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, - { cmd: "forensics", desc: "Examine execution logs" }, - { cmd: "init", desc: "Project init wizard" }, - { cmd: "setup", desc: "Global setup status and configuration" }, - { cmd: "migrate", desc: "Migrate a v1 .planning directory to .gsd format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, - { cmd: "steer", desc: "Hard-steer plan documents during execution" }, - { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, - { cmd: "knowledge", desc: "Add persistent project knowledge" }, - { cmd: "new-milestone", desc: "Create a milestone from a specification document" }, - { cmd: "parallel", desc: "Parallel milestone orchestration" }, - { cmd: "park", desc: "Park a milestone" }, - { cmd: "unpark", desc: "Reactivate a parked milestone" }, - { cmd: "update", desc: "Update SF to the latest version" }, - { cmd: "start", desc: "Start a workflow template" }, - { cmd: "templates", desc: "List available workflow templates" }, - { cmd: "extensions", desc: "Manage extensions" }, - { cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache" }, -] as const; - -function filterStartsWith( - partial: string, - options: ReadonlyArray<{ cmd: string; desc: string }>, - prefix = "", -) { - const normalizedPrefix = prefix.length > 0 ? `${prefix} ` : ""; - return options - .filter((option) => option.cmd.startsWith(partial)) - .map((option) => ({ - value: `${normalizedPrefix}${option.cmd}`, - label: option.cmd, - description: option.desc, - })); -} - -function getGsdArgumentCompletions(prefix: string) { - const parts = prefix.trim().split(/\s+/); - - if (parts.length <= 1) { - return filterStartsWith(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); - } - - const partial = parts[1] ?? ""; - - if (parts[0] === "auto" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], "auto"); - } - - if (parts[0] === "next" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--verbose", desc: "Show detailed step output" }, - { cmd: "--dry-run", desc: "Preview next step without executing" }, - ], "next"); - } - - if (parts[0] === "mode" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "global", desc: "Edit global workflow mode" }, - { cmd: "project", desc: "Edit project-specific workflow mode" }, - ], "mode"); - } - - if (parts[0] === "parallel" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "start", desc: "Start parallel milestone orchestration" }, - { cmd: "status", desc: "Show parallel worker statuses" }, - { cmd: "stop", desc: "Stop all parallel workers" }, - { cmd: "pause", desc: "Pause a specific worker" }, - { cmd: "resume", desc: "Resume a paused worker" }, - { cmd: "merge", desc: "Merge completed milestone branches" }, - ], "parallel"); - } - - if (parts[0] === "setup" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "llm", desc: "Configure LLM provider settings" }, - { cmd: "search", desc: "Configure web search provider" }, - { cmd: "remote", desc: "Configure remote integrations" }, - { cmd: "keys", desc: "Manage API keys" }, - { cmd: "prefs", desc: "Configure global preferences" }, - ], "setup"); - } - - if (parts[0] === "logs" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "debug", desc: "List or view debug log files" }, - { cmd: "tail", desc: "Show last N activity log summaries" }, - { cmd: "clear", desc: "Remove old activity and debug logs" }, - ], "logs"); - } - - if (parts[0] === "keys" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "list", desc: "Show key status dashboard" }, - { cmd: "add", desc: "Add a key for a provider" }, - { cmd: "remove", desc: "Remove a key" }, - { cmd: "test", desc: "Validate key(s) with API call" }, - { cmd: "rotate", desc: "Replace an existing key" }, - { cmd: "doctor", desc: "Health check all keys" }, - ], "keys"); - } - - if (parts[0] === "prefs" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "global", desc: "Edit global preferences file" }, - { cmd: "project", desc: "Edit project preferences file" }, - { cmd: "status", desc: "Show effective preferences" }, - { cmd: "wizard", desc: "Interactive preferences wizard" }, - { cmd: "setup", desc: "First-time preferences setup" }, - { cmd: "import-claude", desc: "Import settings from Claude Code" }, - ], "prefs"); - } - - if (parts[0] === "remote" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "slack", desc: "Configure Slack integration" }, - { cmd: "discord", desc: "Configure Discord integration" }, - { cmd: "status", desc: "Show remote connection status" }, - { cmd: "disconnect", desc: "Disconnect remote integrations" }, - ], "remote"); - } - - if (parts[0] === "history" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--cost", desc: "Show cost breakdown per entry" }, - { cmd: "--phase", desc: "Filter by phase type" }, - { cmd: "--model", desc: "Filter by model used" }, - { cmd: "10", desc: "Show last 10 entries" }, - { cmd: "20", desc: "Show last 20 entries" }, - { cmd: "50", desc: "Show last 50 entries" }, - ], "history"); - } - - if (parts[0] === "export" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "--json", desc: "Export as JSON" }, - { cmd: "--markdown", desc: "Export as Markdown" }, - { cmd: "--html", desc: "Export as HTML" }, - { cmd: "--html --all", desc: "Export all milestones as HTML" }, - ], "export"); - } - - if (parts[0] === "cleanup" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "branches", desc: "Remove merged milestone branches" }, - { cmd: "snapshots", desc: "Remove old execution snapshots" }, - ], "cleanup"); - } - - if (parts[0] === "knowledge" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "rule", desc: "Add a project rule" }, - { cmd: "pattern", desc: "Add a code pattern" }, - { cmd: "lesson", desc: "Record a lesson learned" }, - ], "knowledge"); - } - - if (parts[0] === "start" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "bugfix", desc: "Triage, fix, test, and ship a bug fix" }, - { cmd: "small-feature", desc: "Lightweight feature with optional discussion" }, - { cmd: "spike", desc: "Research, prototype, and document findings" }, - { cmd: "hotfix", desc: "Minimal: fix it, test it, ship it" }, - { cmd: "refactor", desc: "Inventory, plan waves, migrate, verify" }, - { cmd: "security-audit", desc: "Scan, triage, remediate, re-scan" }, - { cmd: "dep-upgrade", desc: "Assess, upgrade, fix breaks, verify" }, - { cmd: "full-project", desc: "Complete SF workflow with full ceremony" }, - { cmd: "resume", desc: "Resume an in-progress workflow" }, - { cmd: "--list", desc: "List all available templates" }, - { cmd: "--dry-run", desc: "Preview workflow without executing" }, - ], "start"); - } - - if (parts[0] === "templates" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "info", desc: "Show detailed template info" }, - ], "templates"); - } - - if (parts[0] === "extensions" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "list", desc: "List all extensions and their status" }, - { cmd: "enable", desc: "Enable a disabled extension" }, - { cmd: "disable", desc: "Disable an extension" }, - { cmd: "info", desc: "Show extension details" }, - ], "extensions"); - } - - if (parts[0] === "codebase" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, - { cmd: "update", desc: "Refresh the CODEBASE.md cache immediately" }, - { cmd: "stats", desc: "Show codebase-map coverage and generation time" }, - { cmd: "help", desc: "Show usage and subcommands" }, - ], "codebase"); - } - - if (parts[0] === "doctor" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "fix", desc: "Auto-fix detected issues" }, - { cmd: "heal", desc: "AI-driven deep healing" }, - { cmd: "audit", desc: "Run health audit without fixing" }, - ], "doctor"); - } - - if (parts[0] === "dispatch" && parts.length <= 2) { - return filterStartsWith(partial, [ - { cmd: "research", desc: "Run research phase" }, - { cmd: "plan", desc: "Run planning phase" }, - { cmd: "execute", desc: "Run execution phase" }, - { cmd: "complete", desc: "Run completion phase" }, - { cmd: "reassess", desc: "Reassess current progress" }, - { cmd: "uat", desc: "Run user acceptance testing" }, - { cmd: "replan", desc: "Replan the current slice" }, - ], "dispatch"); - } - - return null; -} - -export function registerLazyGSDCommand(pi: ExtensionAPI): void { - pi.registerCommand("gsd", { - description: "SF — Singularity Forge", - getArgumentCompletions: getGsdArgumentCompletions, - handler: async (args: string, ctx: ExtensionCommandContext) => { - const { handleGSDCommand } = await importExtensionModule<typeof import("./commands.js")>(import.meta.url, "./commands.js"); - await handleGSDCommand(args, ctx, pi); - }, - }); -} diff --git a/src/resources/extensions/gsd/commands-cmux.ts b/src/resources/extensions/gsd/commands-cmux.ts deleted file mode 100644 index 46e3e2306..000000000 --- a/src/resources/extensions/gsd/commands-cmux.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { clearCmuxSidebar, CmuxClient, detectCmuxEnvironment, resolveCmuxConfig } from "../cmux/index.js"; -import { saveFile } from "./files.js"; -import { - getProjectGSDPreferencesPath, - loadEffectiveGSDPreferences, - loadProjectGSDPreferences, -} from "./preferences.js"; -import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; - -/** - * Auto-enable cmux in project preferences when detected but never configured. - * Called at boot (before agent start) — no ExtensionCommandContext needed. - * Returns true if preferences were written, false if skipped. - */ -export function autoEnableCmuxPreferences(): boolean { - const path = getProjectGSDPreferencesPath(); - if (!existsSync(path)) return false; - - const existing = loadProjectGSDPreferences(); - const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : { version: 1 }; - prefs.cmux = { - enabled: true, - notifications: true, - sidebar: true, - splits: false, - browser: false, - ...((prefs.cmux as Record<string, unknown> | undefined) ?? {}), - }; - (prefs.cmux as Record<string, unknown>).enabled = true; - prefs.version = prefs.version || 1; - - const frontmatter = serializePreferencesToFrontmatter(prefs); - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - - writeFileSync(path, `---\n${frontmatter}---${body}`, "utf-8"); - return true; -} - -function extractBodyAfterFrontmatter(content: string): string | null { - const start = content.startsWith("---\n") ? 4 : content.startsWith("---\r\n") ? 5 : -1; - if (start === -1) return null; - const closingIdx = content.indexOf("\n---", start); - if (closingIdx === -1) return null; - const after = content.slice(closingIdx + 4); - return after.trim() ? after : null; -} - -async function writeProjectCmuxPreferences( - ctx: ExtensionCommandContext, - updater: (prefs: Record<string, unknown>) => void, -): Promise<void> { - const path = getProjectGSDPreferencesPath(); - await ensurePreferencesFile(path, ctx, "project"); - - const existing = loadProjectGSDPreferences(); - const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : { version: 1 }; - updater(prefs); - prefs.version = prefs.version || 1; - - const frontmatter = serializePreferencesToFrontmatter(prefs); - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - } - - await saveFile(path, `---\n${frontmatter}---${body}`); - await ctx.waitForIdle(); - await ctx.reload(); -} - -function formatCmuxStatus(): string { - const loaded = loadEffectiveGSDPreferences(); - const detected = detectCmuxEnvironment(); - const resolved = resolveCmuxConfig(loaded?.preferences); - const capabilities = new CmuxClient(resolved).getCapabilities() as Record<string, unknown> | null; - const accessMode = typeof capabilities?.mode === "string" - ? capabilities.mode - : typeof capabilities?.access_mode === "string" - ? capabilities.access_mode - : "unknown"; - const methods = Array.isArray(capabilities?.methods) ? capabilities.methods.length : 0; - - return [ - "cmux status", - "", - `Detected: ${detected.available ? "yes" : "no"}`, - `Enabled: ${resolved.enabled ? "yes" : "no"}`, - `CLI available: ${detected.cliAvailable ? "yes" : "no"}`, - `Socket: ${detected.socketPath}`, - `Workspace: ${detected.workspaceId ?? "(none)"}`, - `Surface: ${detected.surfaceId ?? "(none)"}`, - `Features: notifications=${resolved.notifications ? "on" : "off"}, sidebar=${resolved.sidebar ? "on" : "off"}, splits=${resolved.splits ? "on" : "off"}, browser=${resolved.browser ? "on" : "off"}`, - `Capabilities: access=${accessMode}, methods=${methods}`, - ].join("\n"); -} - -function ensureCmuxAvailableForEnable(ctx: ExtensionCommandContext): boolean { - const detected = detectCmuxEnvironment(); - if (detected.available) return true; - ctx.ui.notify( - "cmux not detected. Install it from https://cmux.com and run gsd inside a cmux terminal.", - "warning", - ); - return false; -} - -export async function handleCmux(args: string, ctx: ExtensionCommandContext): Promise<void> { - const trimmed = args.trim(); - if (!trimmed || trimmed === "status") { - ctx.ui.notify(formatCmuxStatus(), "info"); - return; - } - - if (trimmed === "on") { - if (!ensureCmuxAvailableForEnable(ctx)) return; - await writeProjectCmuxPreferences(ctx, (prefs) => { - prefs.cmux = { - enabled: true, - notifications: true, - sidebar: true, - splits: false, - browser: false, - ...((prefs.cmux as Record<string, unknown> | undefined) ?? {}), - }; - (prefs.cmux as Record<string, unknown>).enabled = true; - }); - ctx.ui.notify("cmux integration enabled in project preferences.", "info"); - return; - } - - if (trimmed === "off") { - const effective = loadEffectiveGSDPreferences()?.preferences; - await writeProjectCmuxPreferences(ctx, (prefs) => { - prefs.cmux = { ...((prefs.cmux as Record<string, unknown> | undefined) ?? {}), enabled: false }; - }); - clearCmuxSidebar(effective); - ctx.ui.notify("cmux integration disabled in project preferences.", "info"); - return; - } - - const parts = trimmed.split(/\s+/); - if (parts.length === 2 && ["notifications", "sidebar", "splits", "browser"].includes(parts[0]) && ["on", "off"].includes(parts[1])) { - const feature = parts[0] as "notifications" | "sidebar" | "splits" | "browser"; - const enabled = parts[1] === "on"; - if (enabled && !ensureCmuxAvailableForEnable(ctx)) return; - - await writeProjectCmuxPreferences(ctx, (prefs) => { - const next = { ...((prefs.cmux as Record<string, unknown> | undefined) ?? {}) }; - next[feature] = enabled; - if (enabled) next.enabled = true; - prefs.cmux = next; - }); - - if (!enabled && feature === "sidebar") { - clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); - } - - const note = feature === "browser" && enabled - ? " Browser surfaces are still a follow-up path." - : ""; - ctx.ui.notify(`cmux ${feature} ${enabled ? "enabled" : "disabled"}.${note}`, "info"); - return; - } - - ctx.ui.notify( - "Usage: /gsd cmux <status|on|off|notifications on|notifications off|sidebar on|sidebar off|splits on|splits off|browser on|browser off>", - "info", - ); -} diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts deleted file mode 100644 index ccd754303..000000000 --- a/src/resources/extensions/gsd/commands-codebase.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * SF Command — /gsd codebase - * - * Generate and manage the codebase map (.gsd/CODEBASE.md). - * Subcommands: generate, update, stats, help - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { - generateCodebaseMap, - updateCodebaseMap, - writeCodebaseMap, - getCodebaseMapStats, - readCodebaseMap, -} from "./codebase-generator.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import type { CodebaseMapOptions } from "./codebase-generator.js"; - -const USAGE = - "Usage: /gsd codebase [generate|update|stats]\n\n" + - " generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" + - " update [--max-files N] [--collapse-threshold N] — Refresh the CODEBASE.md cache immediately\n" + - " stats — Show file count, coverage, and generation time\n" + - " help — Show this help\n\n" + - "With no subcommand, shows stats if a map exists or help if not.\n" + - "SF also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + - "Configure defaults via preferences.md:\n" + - " codebase:\n" + - " exclude_patterns: [\"docs/\", \"fixtures/\"]\n" + - " max_files: 1000\n" + - " collapse_threshold: 15"; - -export async function handleCodebase( - args: string, - ctx: ExtensionCommandContext, - _pi: ExtensionAPI, -): Promise<void> { - const basePath = process.cwd(); - const parts = args.trim().split(/\s+/); - const sub = parts[0] ?? ""; - - switch (sub) { - case "generate": { - const options = resolveCodebaseOptions(args, ctx); - if (options === false) return; // validation failed, message already shown - - const existing = readCodebaseMap(basePath); - const existingDescriptions = existing - ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) - : undefined; - - const result = generateCodebaseMap(basePath, options, existingDescriptions); - - if (result.fileCount === 0) { - ctx.ui.notify( - "Codebase map generated with 0 files.\n" + - "Is this a git repository? Run 'git ls-files' to verify.", - "warning", - ); - return; - } - - const outPath = writeCodebaseMap(basePath, result.content); - ctx.ui.notify( - `Codebase map generated: ${result.fileCount} files\n` + - `Written to: ${outPath}` + - (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), - "success", - ); - return; - } - - case "update": { - const existing = readCodebaseMap(basePath); - if (!existing) { - ctx.ui.notify( - "No codebase map found. Run /gsd codebase generate to create one.", - "warning", - ); - return; - } - - const options = resolveCodebaseOptions(args, ctx); - if (options === false) return; - - const result = updateCodebaseMap(basePath, options); - writeCodebaseMap(basePath, result.content); - - ctx.ui.notify( - `Codebase map updated: ${result.fileCount} files\n` + - ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` + - (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), - "success", - ); - return; - } - - case "stats": { - showStats(basePath, ctx); - return; - } - - case "help": - ctx.ui.notify(USAGE, "info"); - return; - - case "": { - // Safe default: show stats if map exists, help if not - const existing = readCodebaseMap(basePath); - if (existing) { - showStats(basePath, ctx); - } else { - ctx.ui.notify(USAGE, "info"); - } - return; - } - - default: - ctx.ui.notify( - `Unknown subcommand "${sub}".\n\n${USAGE}`, - "warning", - ); - } -} - -function showStats(basePath: string, ctx: ExtensionCommandContext): void { - const stats = getCodebaseMapStats(basePath); - if (!stats.exists) { - ctx.ui.notify("No codebase map found. Run /gsd codebase generate to create one.", "info"); - return; - } - - const coverage = stats.fileCount > 0 - ? Math.round((stats.describedCount / stats.fileCount) * 100) - : 0; - - ctx.ui.notify( - `Codebase Map Stats:\n` + - ` Files: ${stats.fileCount}\n` + - ` Described: ${stats.describedCount} (${coverage}%)\n` + - ` Undescribed: ${stats.undescribedCount}\n` + - ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + - (stats.undescribedCount > 0 - ? `Tip: Auto-refresh keeps the cache current, but /gsd codebase update forces an immediate refresh.` - : `Coverage is complete.`), - "info", - ); -} - -/** - * Resolve codebase map options by merging preferences with CLI flags. - * CLI flags override preferences; preferences override built-in defaults. - * Returns false if validation failed (error already shown to user). - */ -function resolveCodebaseOptions(args: string, ctx: ExtensionCommandContext): CodebaseMapOptions | false { - // Load preferences defaults - const prefs = loadEffectiveGSDPreferences()?.preferences?.codebase; - - // Parse CLI flags - const maxFilesStr = extractFlag(args, "--max-files"); - const collapseStr = extractFlag(args, "--collapse-threshold"); - - // Validate --max-files - let maxFiles: number | undefined; - if (maxFilesStr) { - maxFiles = parseInt(maxFilesStr, 10); - if (isNaN(maxFiles) || maxFiles < 1) { - ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); - return false; - } - } - - // Validate --collapse-threshold - let collapseThreshold: number | undefined; - if (collapseStr) { - collapseThreshold = parseInt(collapseStr, 10); - if (isNaN(collapseThreshold) || collapseThreshold < 1) { - ctx.ui.notify("--collapse-threshold must be a positive integer (e.g. --collapse-threshold 15).", "warning"); - return false; - } - } - - return { - // CLI flags override preferences - maxFiles: maxFiles ?? prefs?.max_files, - collapseThreshold: collapseThreshold ?? prefs?.collapse_threshold, - excludePatterns: prefs?.exclude_patterns, - }; -} - -function extractFlag(args: string, flag: string): string | undefined { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`); - const match = args.match(regex); - return match?.[1]; -} diff --git a/src/resources/extensions/gsd/commands-config.ts b/src/resources/extensions/gsd/commands-config.ts deleted file mode 100644 index c0e35edd1..000000000 --- a/src/resources/extensions/gsd/commands-config.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * SF Config — Tool API key management. - * - * Contains: TOOL_KEYS, loadToolApiKeys, getConfigAuthStorage, handleConfig - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { AuthStorage } from "@sf-run/pi-coding-agent"; -import { existsSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; - -/** - * Tool API key configurations. - * This is the source of truth for tool credentials - used by both the config wizard - * and session startup to load keys from auth.json into environment variables. - */ -export const TOOL_KEYS = [ - { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" }, - { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" }, - { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" }, - { id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" }, - { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" }, -] as const; - -function getStoredToolKey(auth: AuthStorage, providerId: string): string | undefined { - const creds = auth.getCredentialsForProvider(providerId); - const cred = creds.find((c) => c.type === "api_key" && c.key); - return cred?.type === "api_key" ? cred.key : undefined; -} - -/** - * Load tool API keys from auth.json into environment variables. - * Called at session startup to ensure tools have access to their credentials. - */ -export function loadToolApiKeys(): void { - try { - const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); - if (!existsSync(authPath)) return; - - const auth = AuthStorage.create(authPath); - for (const tool of TOOL_KEYS) { - const key = getStoredToolKey(auth, tool.id); - if (key && !process.env[tool.env]) { - process.env[tool.env] = key; - } - } - } catch { - // Failed to load tool keys — ignore, they can still be set via env vars - } -} - -export function getConfigAuthStorage(): AuthStorage { - const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); - mkdirSync(dirname(authPath), { recursive: true }); - return AuthStorage.create(authPath); -} - -export async function handleConfig(ctx: ExtensionCommandContext): Promise<void> { - const auth = getConfigAuthStorage(); - - // Show current status - const statusLines = ["SF Tool Configuration\n"]; - for (const tool of TOOL_KEYS) { - const hasKey = !!process.env[tool.env] || !!getStoredToolKey(auth, tool.id); - statusLines.push(` ${hasKey ? "\u2713" : "\u2717"} ${tool.label}${hasKey ? "" : ` \u2014 get key at ${tool.hint}`}`); - } - ctx.ui.notify(statusLines.join("\n"), "info"); - - // Ask which tools to configure - const options = TOOL_KEYS.map(t => { - const hasKey = !!process.env[t.env] || !!getStoredToolKey(auth, t.id); - return `${t.label} ${hasKey ? "(configured \u2713)" : "(not set)"}`; - }); - options.push("(done)"); - - let changed = false; - while (true) { - const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options); - if (!choice || typeof choice !== "string" || choice === "(done)") break; - - const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label)); - if (toolIdx === -1) break; - - const tool = TOOL_KEYS[toolIdx]; - const input = await ctx.ui.input( - `API key for ${tool.label} (${tool.hint}):`, - "paste your key here", - ); - - if (input !== null && input !== undefined) { - const key = input.trim(); - if (key) { - auth.set(tool.id, { type: "api_key", key }); - process.env[tool.env] = key; - ctx.ui.notify(`${tool.label} key saved and activated.`, "info"); - // Update option label - options[toolIdx] = `${tool.label} (configured \u2713)`; - changed = true; - } - } - } - - if (changed) { - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info"); - } -} diff --git a/src/resources/extensions/gsd/commands-do.ts b/src/resources/extensions/gsd/commands-do.ts deleted file mode 100644 index bb42c5bb4..000000000 --- a/src/resources/extensions/gsd/commands-do.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * SF Command — /gsd do - * - * Routes freeform natural language to the correct /gsd subcommand - * using keyword matching. Falls back to /gsd quick for task-like input. - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -interface Route { - keywords: string[]; - command: string; -} - -const ROUTES: Route[] = [ - { keywords: ["progress", "status", "dashboard", "how far", "where are we"], command: "status" }, - { keywords: ["auto", "autonomous", "run all", "keep going", "start auto"], command: "auto" }, - { keywords: ["stop", "halt", "abort"], command: "stop" }, - { keywords: ["pause", "break", "take a break"], command: "pause" }, - { keywords: ["history", "past", "what happened", "previous"], command: "history" }, - { keywords: ["doctor", "health", "diagnose", "check health"], command: "doctor" }, - { keywords: ["clean up", "cleanup", "remove old", "prune", "tidy"], command: "cleanup" }, - { keywords: ["export", "report", "share results"], command: "export" }, - { keywords: ["ship", "pull request", "create pr", "open pr", "merge"], command: "ship" }, - { keywords: ["discuss", "talk about", "architecture", "design"], command: "discuss" }, - { keywords: ["undo", "revert", "rollback", "take back"], command: "undo" }, - { keywords: ["skip", "skip task", "skip this"], command: "skip" }, - { keywords: ["queue", "reorder", "milestone order", "order milestones"], command: "queue" }, - { keywords: ["visualize", "viz", "graph", "chart", "show graph"], command: "visualize" }, - { keywords: ["capture", "note", "idea", "thought", "remember"], command: "capture" }, - { keywords: ["inspect", "database", "sqlite", "db state"], command: "inspect" }, - { keywords: ["knowledge", "rule", "pattern", "lesson"], command: "knowledge" }, - { keywords: ["session report", "session summary", "cost summary", "how much"], command: "session-report" }, - { keywords: ["backlog", "parking lot", "later", "someday"], command: "backlog" }, - { keywords: ["pr branch", "clean branch", "filter commits"], command: "pr-branch" }, - { keywords: ["add tests", "write tests", "generate tests", "test coverage"], command: "add-tests" }, - { keywords: ["next", "step", "next step", "what's next"], command: "next" }, - { keywords: ["migrate", "migration", "convert", "upgrade"], command: "migrate" }, - { keywords: ["steer", "change direction", "pivot", "redirect"], command: "steer" }, - { keywords: ["park", "shelve", "set aside"], command: "park" }, - { keywords: ["widget", "toggle widget"], command: "widget" }, - { keywords: ["logs", "debug logs", "log files"], command: "logs" }, -]; - -interface MatchResult { - command: string; - remainingArgs: string; - score: number; -} - -function matchRoute(input: string): MatchResult | null { - const lower = input.toLowerCase(); - let bestMatch: MatchResult | null = null; - - for (const route of ROUTES) { - for (const keyword of route.keywords) { - if (lower.includes(keyword)) { - const score = keyword.length; // Longer match = higher confidence - if (!bestMatch || score > bestMatch.score) { - // Strip the matched keyword from input to get remaining args - const idx = lower.indexOf(keyword); - const remaining = (input.slice(0, idx) + input.slice(idx + keyword.length)).trim(); - bestMatch = { command: route.command, remainingArgs: remaining, score }; - } - } - } - } - - return bestMatch; -} - -export async function handleDo( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - if (!args.trim()) { - ctx.ui.notify( - "Usage: /gsd do <what you want to do>\n\n" + - "Examples:\n" + - " /gsd do show me progress\n" + - " /gsd do run autonomously\n" + - " /gsd do clean up old branches\n" + - " /gsd do fix the login bug", - "warning", - ); - return; - } - - const match = matchRoute(args); - - if (match) { - const fullCommand = match.remainingArgs - ? `${match.command} ${match.remainingArgs}` - : match.command; - - ctx.ui.notify(`→ /gsd ${fullCommand}`, "info"); - - // Re-dispatch through the main dispatcher - const { handleGSDCommand } = await import("./commands/dispatcher.js"); - await handleGSDCommand(fullCommand, ctx, pi); - return; - } - - // No keyword match → treat as quick task - ctx.ui.notify(`→ /gsd quick ${args}`, "info"); - const { handleQuick } = await import("./quick.js"); - await handleQuick(args, ctx, pi); -} diff --git a/src/resources/extensions/gsd/commands-extensions.ts b/src/resources/extensions/gsd/commands-extensions.ts deleted file mode 100644 index 448416e8e..000000000 --- a/src/resources/extensions/gsd/commands-extensions.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * SF Extensions Command — /gsd extensions - * - * Manage the extension registry: list, enable, disable, info. - * Self-contained — no imports outside the extensions tree (extensions are loaded - * via jiti at runtime from ~/.gsd/agent/, not compiled by tsc). - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; - -const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); - -// ─── Types (mirrored from extension-registry.ts) ──────────────────────────── - -interface ExtensionManifest { - id: string; - name: string; - version: string; - description: string; - tier: "core" | "bundled" | "community"; - requires: { platform: string }; - provides?: { - tools?: string[]; - commands?: string[]; - hooks?: string[]; - shortcuts?: string[]; - }; - dependencies?: { - extensions?: string[]; - runtime?: string[]; - }; -} - -interface ExtensionRegistryEntry { - id: string; - enabled: boolean; - source: "bundled" | "user" | "project"; - disabledAt?: string; - disabledReason?: string; -} - -interface ExtensionRegistry { - version: 1; - entries: Record<string, ExtensionRegistryEntry>; -} - -// ─── Registry I/O ─────────────────────────────────────────────────────────── - -function getRegistryPath(): string { - return join(gsdHome, "extensions", "registry.json"); -} - -function getAgentExtensionsDir(): string { - return join(gsdHome, "agent", "extensions"); -} - -function loadRegistry(): ExtensionRegistry { - const filePath = getRegistryPath(); - try { - if (!existsSync(filePath)) return { version: 1, entries: {} }; - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - if (typeof parsed === "object" && parsed !== null && parsed.version === 1 && typeof parsed.entries === "object") { - return parsed as ExtensionRegistry; - } - return { version: 1, entries: {} }; - } catch { - return { version: 1, entries: {} }; - } -} - -function saveRegistry(registry: ExtensionRegistry): void { - const filePath = getRegistryPath(); - try { - mkdirSync(dirname(filePath), { recursive: true }); - const tmp = filePath + ".tmp"; - writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8"); - renameSync(tmp, filePath); - } catch { /* non-fatal */ } -} - -function isEnabled(registry: ExtensionRegistry, id: string): boolean { - const entry = registry.entries[id]; - if (!entry) return true; - return entry.enabled; -} - -function readManifest(dir: string): ExtensionManifest | null { - const mPath = join(dir, "extension-manifest.json"); - if (!existsSync(mPath)) return null; - try { - const raw = JSON.parse(readFileSync(mPath, "utf-8")); - if (typeof raw?.id === "string" && typeof raw?.name === "string") return raw as ExtensionManifest; - return null; - } catch { - return null; - } -} - -function discoverManifests(): Map<string, ExtensionManifest> { - const extDir = getAgentExtensionsDir(); - const manifests = new Map<string, ExtensionManifest>(); - if (!existsSync(extDir)) return manifests; - for (const entry of readdirSync(extDir, { withFileTypes: true })) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; - const m = readManifest(join(extDir, entry.name)); - if (m) manifests.set(m.id, m); - } - return manifests; -} - -// ─── Command Handler ──────────────────────────────────────────────────────── - -export async function handleExtensions(args: string, ctx: ExtensionCommandContext): Promise<void> { - const parts = args.split(/\s+/).filter(Boolean); - const subCmd = parts[0] ?? "list"; - - if (subCmd === "list") { - handleList(ctx); - return; - } - - if (subCmd === "enable") { - handleEnable(parts[1], ctx); - return; - } - - if (subCmd === "disable") { - handleDisable(parts[1], parts.slice(2).join(" "), ctx); - return; - } - - if (subCmd === "info") { - handleInfo(parts[1], ctx); - return; - } - - ctx.ui.notify( - `Unknown: /gsd extensions ${subCmd}. Usage: /gsd extensions [list|enable|disable|info]`, - "warning", - ); -} - -function handleList(ctx: ExtensionCommandContext): void { - const manifests = discoverManifests(); - const registry = loadRegistry(); - - if (manifests.size === 0) { - ctx.ui.notify("No extension manifests found.", "warning"); - return; - } - - // Sort: core first, then alphabetical - const sorted = [...manifests.values()].sort((a, b) => { - if (a.tier === "core" && b.tier !== "core") return -1; - if (b.tier === "core" && a.tier !== "core") return 1; - return a.id.localeCompare(b.id); - }); - - const lines: string[] = []; - const hdr = padRight("Extensions", 38) + padRight("Status", 10) + padRight("Tier", 10) + padRight("Tools", 7) + "Commands"; - lines.push(hdr); - lines.push("─".repeat(hdr.length)); - - for (const m of sorted) { - const enabled = isEnabled(registry, m.id); - const status = enabled ? "enabled" : "disabled"; - const toolCount = m.provides?.tools?.length ?? 0; - const cmdCount = m.provides?.commands?.length ?? 0; - const label = `${m.id} (${m.name})`; - - lines.push( - padRight(label, 38) + - padRight(status, 10) + - padRight(m.tier, 10) + - padRight(String(toolCount), 7) + - String(cmdCount), - ); - - if (!enabled) { - lines.push(` ↳ gsd extensions enable ${m.id}`); - } - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -function handleEnable(id: string | undefined, ctx: ExtensionCommandContext): void { - if (!id) { - ctx.ui.notify("Usage: /gsd extensions enable <id>", "warning"); - return; - } - - const manifests = discoverManifests(); - if (!manifests.has(id)) { - ctx.ui.notify(`Extension "${id}" not found. Run /gsd extensions list to see available extensions.`, "warning"); - return; - } - - const registry = loadRegistry(); - if (isEnabled(registry, id)) { - ctx.ui.notify(`Extension "${id}" is already enabled.`, "info"); - return; - } - - const entry = registry.entries[id]; - if (entry) { - entry.enabled = true; - delete entry.disabledAt; - delete entry.disabledReason; - } else { - registry.entries[id] = { id, enabled: true, source: "bundled" }; - } - saveRegistry(registry); - ctx.ui.notify(`Enabled "${id}". Restart SF to activate.`, "info"); -} - -function handleDisable(id: string | undefined, reason: string, ctx: ExtensionCommandContext): void { - if (!id) { - ctx.ui.notify("Usage: /gsd extensions disable <id>", "warning"); - return; - } - - const manifests = discoverManifests(); - const manifest = manifests.get(id) ?? null; - - if (!manifests.has(id)) { - ctx.ui.notify(`Extension "${id}" not found. Run /gsd extensions list to see available extensions.`, "warning"); - return; - } - - if (manifest?.tier === "core") { - ctx.ui.notify(`Cannot disable "${id}" — it is a core extension.`, "warning"); - return; - } - - const registry = loadRegistry(); - if (!isEnabled(registry, id)) { - ctx.ui.notify(`Extension "${id}" is already disabled.`, "info"); - return; - } - - const entry = registry.entries[id]; - if (entry) { - entry.enabled = false; - entry.disabledAt = new Date().toISOString(); - entry.disabledReason = reason || undefined; - } else { - registry.entries[id] = { - id, - enabled: false, - source: "bundled", - disabledAt: new Date().toISOString(), - disabledReason: reason || undefined, - }; - } - saveRegistry(registry); - ctx.ui.notify(`Disabled "${id}". Restart SF to deactivate.`, "info"); -} - -function handleInfo(id: string | undefined, ctx: ExtensionCommandContext): void { - if (!id) { - ctx.ui.notify("Usage: /gsd extensions info <id>", "warning"); - return; - } - - const manifests = discoverManifests(); - const manifest = manifests.get(id); - if (!manifest) { - ctx.ui.notify(`Extension "${id}" not found.`, "warning"); - return; - } - - const registry = loadRegistry(); - const enabled = isEnabled(registry, id); - const entry = registry.entries[id]; - - const lines: string[] = [ - `${manifest.name} (${manifest.id})`, - "", - ` Version: ${manifest.version}`, - ` Description: ${manifest.description}`, - ` Tier: ${manifest.tier}`, - ` Status: ${enabled ? "enabled" : "disabled"}`, - ]; - - if (entry?.disabledAt) { - lines.push(` Disabled at: ${entry.disabledAt}`); - } - if (entry?.disabledReason) { - lines.push(` Reason: ${entry.disabledReason}`); - } - - if (manifest.provides) { - lines.push(""); - lines.push(" Provides:"); - if (manifest.provides.tools?.length) { - lines.push(` Tools: ${manifest.provides.tools.join(", ")}`); - } - if (manifest.provides.commands?.length) { - lines.push(` Commands: ${manifest.provides.commands.join(", ")}`); - } - if (manifest.provides.hooks?.length) { - lines.push(` Hooks: ${manifest.provides.hooks.join(", ")}`); - } - if (manifest.provides.shortcuts?.length) { - lines.push(` Shortcuts: ${manifest.provides.shortcuts.join(", ")}`); - } - } - - if (manifest.dependencies) { - lines.push(""); - lines.push(" Dependencies:"); - if (manifest.dependencies.extensions?.length) { - lines.push(` Extensions: ${manifest.dependencies.extensions.join(", ")}`); - } - if (manifest.dependencies.runtime?.length) { - lines.push(` Runtime: ${manifest.dependencies.runtime.join(", ")}`); - } - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -function padRight(str: string, len: number): string { - return str.length >= len ? str + " " : str + " ".repeat(len - str.length); -} diff --git a/src/resources/extensions/gsd/commands-extract-learnings.ts b/src/resources/extensions/gsd/commands-extract-learnings.ts deleted file mode 100644 index 7c8e9793f..000000000 --- a/src/resources/extensions/gsd/commands-extract-learnings.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * SF Command — /gsd extract-learnings - * - * Analyses completed milestone artefacts and dispatches an LLM turn that - * extracts structured knowledge into 4 categories: - * Decisions · Lessons · Patterns · Surprises - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync } from "node:fs"; -import { join, basename } from "node:path"; - -import { gsdRoot, resolveMilestonePath } from "./paths.js"; -import { projectRoot } from "./commands/context.js"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface PhaseArtifacts { - plan: string | null; - summary: string | null; - verification: string | null; - uat: string | null; - missingRequired: string[]; -} - -export interface ExtractLearningsPromptContext { - milestoneId: string; - milestoneName: string; - outputPath: string; - relativeOutputPath: string; - planContent: string; - summaryContent: string; - verificationContent: string | null; - uatContent: string | null; - missingArtifacts: string[]; - projectName: string; -} - -export interface FrontmatterContext { - milestoneId: string; - milestoneName: string; - projectName: string; - generatedAt: string; - counts: { - decisions: number; - lessons: number; - patterns: number; - surprises: number; - }; - missingArtifacts: string[]; -} - -// ─── Pure functions ─────────────────────────────────────────────────────────── - -export function parseExtractLearningsArgs(args: string): { milestoneId: string | null } { - const trimmed = args.trim(); - return { milestoneId: trimmed || null }; -} - -export function buildLearningsOutputPath(milestoneDir: string, milestoneId: string): string { - return join(milestoneDir, `${milestoneId}-LEARNINGS.md`); -} - -export function resolvePhaseArtifacts(milestoneDir: string, milestoneId: string): PhaseArtifacts { - const missingRequired: string[] = []; - - const planFile = `${milestoneId}-PLAN.md`; - const summaryFile = `${milestoneId}-SUMMARY.md`; - const verificationFile = `${milestoneId}-VERIFICATION.md`; - const uatFile = `${milestoneId}-UAT.md`; - - const planPath = join(milestoneDir, planFile); - const summaryPath = join(milestoneDir, summaryFile); - const verificationPath = join(milestoneDir, verificationFile); - const uatPath = join(milestoneDir, uatFile); - - const plan = existsSync(planPath) ? planPath : null; - const summary = existsSync(summaryPath) ? summaryPath : null; - const verification = existsSync(verificationPath) ? verificationPath : null; - const uat = existsSync(uatPath) ? uatPath : null; - - if (!plan) missingRequired.push(planFile); - if (!summary) missingRequired.push(summaryFile); - - return { plan, summary, verification, uat, missingRequired }; -} - -export function buildExtractLearningsPrompt(ctx: ExtractLearningsPromptContext): string { - const optionalSections: string[] = []; - - if (ctx.verificationContent) { - optionalSections.push(`## Verification Report\n\n${ctx.verificationContent}`); - } - if (ctx.uatContent) { - optionalSections.push(`## UAT Report\n\n${ctx.uatContent}`); - } - - const missingNote = ctx.missingArtifacts.length > 0 - ? `\nNote: The following optional artefacts were not available: ${ctx.missingArtifacts.join(", ")}\n` - : ""; - - return `# Extract Learnings — ${ctx.milestoneId}: ${ctx.milestoneName} - -**Project:** ${ctx.projectName} -**Output file:** ${ctx.outputPath} - -## Your Task - -Analyse the artefacts below and extract structured knowledge from milestone **${ctx.milestoneId}**. - -Write a LEARNINGS document to \`${ctx.outputPath}\` with the following 4 sections: - -### Decisions -Key architectural and design decisions made during this milestone, including the rationale and alternatives considered. - -### Lessons -What the team learned — technical discoveries, process insights, and knowledge gaps that were filled. - -### Patterns -Reusable patterns, approaches, or solutions that emerged and should be applied in future work. - -### Surprises -Unexpected challenges, discoveries, or outcomes — things that deviated from assumptions. - -### Source Attribution (REQUIRED) - -Every extracted item MUST include a \`Source:\` line immediately after the item text. -Format: \`Source: {artifact-filename}/{section}\` -Example: \`Source: M001-PLAN.md/Architecture Decisions\` - -Items without a Source attribution are invalid and must not be included in the output. - ---- - -## Artefacts - -### Plan - -${ctx.planContent} - ---- - -### Summary - -${ctx.summaryContent} - -${optionalSections.join("\n\n---\n\n")} -${missingNote} ---- - -## Output Format - -Write the LEARNINGS file to \`${ctx.relativeOutputPath}\` with YAML frontmatter followed by the 4 sections above. -Each section should contain concise, actionable bullet points. -Every bullet point MUST be followed by a source line, for example: - -\`\`\` -### Decisions -- Chose PostgreSQL over SQLite for concurrent write support. - Source: M001-PLAN.md/Architecture Decisions -\`\`\` - -Items without a \`Source:\` line are invalid. - ---- - -## Optional: Capture Individual Learnings - -If the \`capture_thought\` tool is available, call it once for each extracted item with: -- category: "decision" | "lesson" | "pattern" | "surprise" -- phase: "${ctx.milestoneId}" -- content: {the learning text} -- source: {artifact filename} - -If \`capture_thought\` is not available, skip this step silently — do not report an error. - ---- - -## Rebuild Knowledge Graph - -After writing LEARNINGS.md, call the \`gsd_graph\` tool with \`{ "mode": "build" }\` to rebuild the knowledge graph so the new learnings are immediately queryable by future milestone prompts. - -If the \`gsd_graph\` tool is not available, skip this step silently. -`; -} - -export function buildFrontmatter(ctx: FrontmatterContext): string { - const missingList = ctx.missingArtifacts.length > 0 - ? ctx.missingArtifacts.map((a) => ` - ${a}`).join("\n") - : " []"; - - const missingValue = ctx.missingArtifacts.length > 0 - ? `\n${missingList}` - : " []"; - - return `--- -phase: ${ctx.milestoneId} -phase_name: ${ctx.milestoneName} -project: ${ctx.projectName} -generated: ${ctx.generatedAt} -counts: - decisions: ${ctx.counts.decisions} - lessons: ${ctx.counts.lessons} - patterns: ${ctx.counts.patterns} - surprises: ${ctx.counts.surprises} -missing_artifacts:${missingValue} ----`; -} - -export function extractProjectName(basePath: string): string { - const projectMdPath = join(gsdRoot(basePath), "PROJECT.md"); - - if (existsSync(projectMdPath)) { - try { - const content = readFileSync(projectMdPath, "utf-8"); - const match = content.match(/^name:\s*(.+)$/m); - if (match) return match[1].trim(); - } catch { - // non-fatal - } - } - - return basename(basePath); -} - -// ─── Handler ────────────────────────────────────────────────────────────────── - -export async function handleExtractLearnings( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - const { milestoneId } = parseExtractLearningsArgs(args); - - if (!milestoneId) { - ctx.ui.notify("Usage: /gsd extract-learnings <milestoneId> (e.g. M001)", "warning"); - return; - } - - // projectRoot() throws GSDNoProjectError if no project found — intentional, handled by dispatcher - const basePath = projectRoot(); - const milestoneDir = resolveMilestonePath(basePath, milestoneId); - - if (!milestoneDir) { - ctx.ui.notify(`Milestone not found: ${milestoneId}`, "error"); - return; - } - - const artifacts = resolvePhaseArtifacts(milestoneDir, milestoneId); - - if (artifacts.missingRequired.length > 0) { - ctx.ui.notify( - `Cannot extract learnings — required artefacts missing: ${artifacts.missingRequired.join(", ")}`, - "error", - ); - return; - } - - // Read required artefacts - const planContent = readFileSync(artifacts.plan!, "utf-8"); - const summaryContent = readFileSync(artifacts.summary!, "utf-8"); - - // Read optional artefacts - const verificationContent = artifacts.verification - ? readFileSync(artifacts.verification, "utf-8") - : null; - const uatContent = artifacts.uat - ? readFileSync(artifacts.uat, "utf-8") - : null; - - // Determine missing optional artefacts for context - const missingArtifacts: string[] = []; - if (!artifacts.verification) missingArtifacts.push(`${milestoneId}-VERIFICATION.md`); - if (!artifacts.uat) missingArtifacts.push(`${milestoneId}-UAT.md`); - - // Extract milestone name from Plan H1 or fall back to milestoneId - const h1Match = planContent.match(/^#\s+(.+)$/m); - const milestoneName = h1Match?.[1]?.trim() ?? milestoneId; - - const projectName = extractProjectName(basePath); - const outputPath = buildLearningsOutputPath(milestoneDir, milestoneId); - const relativeOutputPath = outputPath.replace(basePath + "/", ""); - - const prompt = buildExtractLearningsPrompt({ - milestoneId, - milestoneName, - outputPath, - relativeOutputPath, - planContent, - summaryContent, - verificationContent, - uatContent, - missingArtifacts, - projectName, - }); - - ctx.ui.notify(`Extracting learnings for ${milestoneId}: "${milestoneName}"...`, "info"); - - pi.sendMessage( - { customType: "gsd-extract-learnings", content: prompt, display: false }, - { triggerTurn: true }, - ); -} diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts deleted file mode 100644 index e2dc6ff2c..000000000 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * SF Command Handlers — fire-and-forget handlers that delegate to other modules. - * - * Contains: handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, - * handleRunHook, handleUpdate, handleSkillHealth - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, readFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { deriveState } from "./state.js"; -import { gsdRoot } from "./paths.js"; -import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; -import { appendOverride, appendKnowledge } from "./files.js"; -import { - formatDoctorIssuesForPrompt, - formatDoctorReport, - formatDoctorReportJson, - runGSDDoctor, - selectDoctorScope, - filterDoctorIssues, -} from "./doctor.js"; -import { isAutoActive, checkRemoteAutoSession } from "./auto.js"; -import { getAutoWorktreePath } from "./auto-worktree.js"; -import { projectRoot } from "./commands/context.js"; -import { loadPrompt } from "./prompt-loader.js"; - -const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/sf-run/latest"; -const UPDATE_FETCH_TIMEOUT_MS = 5000; - -function resolveInstallCommand(pkg: string): string { - if ('bun' in process.versions) return `bun add -g ${pkg}`; - return `npm install -g ${pkg}`; -} - -async function fetchLatestVersionForCommand(): Promise<string | null> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS); - - try { - const res = await fetch(UPDATE_REGISTRY_URL, { signal: controller.signal }); - if (!res.ok) return null; - const data = (await res.json()) as { version?: string }; - const latest = typeof data.version === "string" ? data.version.trim().replace(/^v/, "") : ""; - return latest.length > 0 ? latest : null; - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { - const workflowPath = process.env.SF_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "SF-WORKFLOW.md"); - const workflow = readFileSync(workflowPath, "utf-8"); - const prompt = loadPrompt("doctor-heal", { - doctorSummary: reportText, - structuredIssues, - scopeLabel: scope ?? "active milestone / blocking scope", - doctorCommandSuffix: scope ? ` ${scope}` : "", - }); - - const content = `Read the following SF workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`; - - pi.sendMessage( - { customType: "gsd-doctor-heal", content, display: false }, - { triggerTurn: true }, - ); -} - -/** Parse doctor command args into structured flags and positionals (pure, no I/O). */ -export function parseDoctorArgs(args: string) { - const trimmed = args.trim(); - const jsonMode = trimmed.includes("--json"); - const dryRun = trimmed.includes("--dry-run"); - const fixFlag = trimmed.includes("--fix"); - const includeBuild = trimmed.includes("--build"); - const includeTests = trimmed.includes("--test"); - const stripped = trimmed.replace(/--json|--dry-run|--build|--test|--fix/g, "").trim(); - const parts = stripped ? stripped.split(/\s+/) : []; - const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor"; - const requestedScope = mode === "doctor" ? parts[0] : parts[1]; - return { jsonMode, dryRun, fixFlag, includeBuild, includeTests, mode, requestedScope }; -} - -export function isDoctorHealActionable(issue: { fixable: boolean; severity: string }): boolean { - return issue.fixable && issue.severity !== "info"; -} - -export async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> { - const { jsonMode, dryRun, fixFlag, includeBuild, includeTests, mode, requestedScope } = parseDoctorArgs(args); - const scope = await selectDoctorScope(projectRoot(), requestedScope); - const effectiveScope = mode === "audit" ? requestedScope : scope; - const report = await runGSDDoctor(projectRoot(), { - fix: mode === "fix" || mode === "heal" || dryRun || fixFlag, - dryRun, - scope: effectiveScope, - includeBuild, - includeTests, - }); - - if (jsonMode) { - ctx.ui.notify(formatDoctorReportJson(report), "info"); - return; - } - - const reportText = formatDoctorReport(report, { - scope: effectiveScope, - includeWarnings: mode === "audit", - maxIssues: mode === "audit" ? 50 : 12, - title: mode === "audit" ? "SF doctor audit." : mode === "heal" ? "SF doctor heal prep." : undefined, - }); - - ctx.ui.notify(reportText, report.ok ? "info" : "warning"); - - if (mode === "heal") { - const unresolved = filterDoctorIssues(report.issues, { - scope: effectiveScope, - includeWarnings: true, - }); - const actionable = unresolved.filter(isDoctorHealActionable); - if (actionable.length === 0) { - ctx.ui.notify("Doctor heal found nothing actionable to hand off to the LLM.", "info"); - return; - } - - const structuredIssues = formatDoctorIssuesForPrompt(actionable); - dispatchDoctorHeal(pi, effectiveScope, reportText, structuredIssues); - ctx.ui.notify(`Doctor heal dispatched ${actionable.length} issue(s) to the LLM.`, "info"); - } -} - -export async function handleSkillHealth(args: string, ctx: ExtensionCommandContext): Promise<void> { - const { - generateSkillHealthReport, - formatSkillHealthReport, - formatSkillDetail, - } = await import("./skill-health.js"); - - const basePath = projectRoot(); - - // /gsd skill-health <skill-name> — detail view - if (args && !args.startsWith("--")) { - const detail = formatSkillDetail(basePath, args); - ctx.ui.notify(detail, "info"); - return; - } - - // Parse flags - const staleMatch = args.match(/--stale\s+(\d+)/); - const staleDays = staleMatch ? parseInt(staleMatch[1], 10) : undefined; - const decliningOnly = args.includes("--declining"); - - const report = generateSkillHealthReport(basePath, staleDays); - - if (decliningOnly) { - if (report.decliningSkills.length === 0) { - ctx.ui.notify("No skills flagged for declining performance.", "info"); - return; - } - const filtered = { - ...report, - skills: report.skills.filter(s => s.flagged), - }; - ctx.ui.notify(formatSkillHealthReport(filtered), "info"); - return; - } - - ctx.ui.notify(formatSkillHealthReport(report), "info"); -} - -export async function handleCapture(args: string, ctx: ExtensionCommandContext): Promise<void> { - // Strip surrounding quotes from the argument - let text = args.trim(); - if (!text) { - ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning"); - return; - } - // Remove wrapping quotes (single or double) - if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { - text = text.slice(1, -1); - } - if (!text) { - ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning"); - return; - } - - const basePath = process.cwd(); - - // Ensure .gsd/ exists — capture should work even without a milestone - const gsdDir = gsdRoot(basePath); - if (!existsSync(gsdDir)) { - mkdirSync(gsdDir, { recursive: true }); - } - - const id = appendCapture(basePath, text); - ctx.ui.notify(`Captured: ${id} — "${text.length > 60 ? text.slice(0, 57) + "..." : text}"`, "info"); -} - -export async function handleTriage(ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string): Promise<void> { - if (!hasPendingCaptures(basePath)) { - ctx.ui.notify("No pending captures to triage.", "info"); - return; - } - - const pending = loadPendingCaptures(basePath); - ctx.ui.notify(`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, "info"); - - // Build context for the triage prompt - const state = await deriveState(basePath); - let currentPlan = ""; - let roadmapContext = ""; - - if (state.activeMilestone && state.activeSlice) { - const { resolveSliceFile, resolveMilestoneFile } = await import("./paths.js"); - const planFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "PLAN"); - if (planFile) { - const { loadFile: load } = await import("./files.js"); - currentPlan = (await load(planFile)) ?? ""; - } - const roadmapFile = resolveMilestoneFile(basePath, state.activeMilestone.id, "ROADMAP"); - if (roadmapFile) { - const { loadFile: load } = await import("./files.js"); - roadmapContext = (await load(roadmapFile)) ?? ""; - } - } - - // Format pending captures for the prompt - const capturesList = pending.map(c => - `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` - ).join("\n"); - - // Dispatch triage prompt - const { loadPrompt: loadTriagePrompt } = await import("./prompt-loader.js"); - const prompt = loadTriagePrompt("triage-captures", { - pendingCaptures: capturesList, - currentPlan: currentPlan || "(no active slice plan)", - roadmapContext: roadmapContext || "(no active roadmap)", - }); - - const workflowPath = process.env.SF_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "SF-WORKFLOW.md"); - const workflow = readFileSync(workflowPath, "utf-8"); - - pi.sendMessage( - { - customType: "gsd-triage", - content: `Read the following SF workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`, - display: false, - }, - { triggerTurn: true }, - ); -} - -export async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> { - const basePath = process.cwd(); - const state = await deriveState(basePath); - const mid = state.activeMilestone?.id ?? "none"; - const sid = state.activeSlice?.id ?? "none"; - const tid = state.activeTask?.id ?? "none"; - const appliedAt = `${mid}/${sid}/${tid}`; - - // Resolve the correct target path: only route to a worktree when auto-mode - // is actively running there (in-process or remote). A worktree directory may - // exist from a previous session without being the active runtime path — - // writing there without a live session would silently drop the override. - const autoRunning = isAutoActive() || checkRemoteAutoSession(basePath).running; - const wtPath = autoRunning && mid !== "none" - ? getAutoWorktreePath(basePath, mid) - : null; - const targetPath = wtPath ?? basePath; - await appendOverride(targetPath, change, appliedAt); - - const overrideLoc = wtPath ? "worktree `.gsd/OVERRIDES.md`" : "`.gsd/OVERRIDES.md`"; - - if (isAutoActive()) { - pi.sendMessage({ - customType: "gsd-hard-steer", - content: [ - "HARD STEER — User override registered.", - "", - `**Override:** ${change}`, - "", - `This override has been saved to ${overrideLoc} and will be injected into all future task prompts.`, - "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", - "", - "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", - ].join("\n"), - display: false, - }, { triggerTurn: true }); - ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Will be applied before next task dispatch.`, "info"); - } else { - pi.sendMessage({ - customType: "gsd-hard-steer", - content: [ - "HARD STEER — User override registered.", - "", - `**Override:** ${change}`, - "", - `This override has been saved to ${overrideLoc}.`, - `Before continuing, read ${overrideLoc} and update the current plan documents to reflect this change.`, - "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", - ].join("\n"), - display: false, - }, { triggerTurn: true }); - ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Update plan documents to reflect this change.`, "info"); - } -} - -export async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Promise<void> { - const parts = args.split(/\s+/); - const typeArg = parts[0]?.toLowerCase(); - - if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) { - ctx.ui.notify( - "Usage: /gsd knowledge <rule|pattern|lesson> <description>\nExample: /gsd knowledge rule Use real DB for integration tests", - "warning", - ); - return; - } - - const entryText = parts.slice(1).join(" ").trim(); - if (!entryText) { - ctx.ui.notify(`Usage: /gsd knowledge ${typeArg} <description>`, "warning"); - return; - } - - const type = typeArg as "rule" | "pattern" | "lesson"; - const basePath = process.cwd(); - const state = await deriveState(basePath); - const scope = state.activeMilestone?.id - ? `${state.activeMilestone.id}${state.activeSlice ? `/${state.activeSlice.id}` : ""}` - : "global"; - - await appendKnowledge(basePath, type, entryText, scope); - ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success"); -} - -export async function handleRunHook(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> { - const parts = args.trim().split(/\s+/); - if (parts.length < 3) { - ctx.ui.notify(`Usage: /gsd run-hook <hook-name> <unit-type> <unit-id> - -Unit types: - execute-task - Task execution (unit-id: M001/S01/T01) - plan-slice - Slice planning (unit-id: M001/S01) - research-milestone - Milestone research (unit-id: M001) - complete-slice - Slice completion (unit-id: M001/S01) - complete-milestone - Milestone completion (unit-id: M001) - -Examples: - /gsd run-hook code-review execute-task M001/S01/T01 - /gsd run-hook lint-check plan-slice M001/S01`, "warning"); - return; - } - - const [hookName, unitType, unitId] = parts; - const basePath = projectRoot(); - - // Import the hook trigger function - const { triggerHookManually, formatHookStatus, getHookStatus } = await import("./post-unit-hooks.js"); - const { dispatchHookUnit } = await import("./auto.js"); - - // Check if the hook exists - const hooks = getHookStatus(); - const hookExists = hooks.some(h => h.name === hookName); - if (!hookExists) { - ctx.ui.notify(`Hook "${hookName}" not found. Configured hooks:\n${formatHookStatus()}`, "error"); - return; - } - - // Validate unit ID format - const unitIdPattern = /^M\d{3}\/S\d{2,3}\/T\d{2,3}$/; - if (!unitIdPattern.test(unitId)) { - ctx.ui.notify(`Invalid unit ID format: "${unitId}". Expected format: M004/S04/T03`, "warning"); - return; - } - - // Trigger the hook manually - const hookUnit = triggerHookManually(hookName, unitType, unitId, basePath); - if (!hookUnit) { - ctx.ui.notify(`Failed to trigger hook "${hookName}". The hook may be disabled or not configured for unit type "${unitType}".`, "error"); - return; - } - - ctx.ui.notify(`Manually triggering hook: ${hookName} for ${unitType} ${unitId}`, "info"); - - // Dispatch the hook unit directly, bypassing normal pre-dispatch hooks - const success = await dispatchHookUnit( - ctx, - pi, - hookName, - unitType, - unitId, - hookUnit.prompt, - hookUnit.model, - basePath, - ); - - if (!success) { - ctx.ui.notify("Failed to dispatch hook. Auto-mode may have been cancelled.", "error"); - } -} - -// ─── Self-update handler ──────────────────────────────────────────────────── - -function compareSemverLocal(a: string, b: string): number { - const pa = a.split('.').map(Number) - const pb = b.split('.').map(Number) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const va = pa[i] || 0 - const vb = pb[i] || 0 - if (va > vb) return 1 - if (va < vb) return -1 - } - return 0 -} - -export async function handleUpdate(ctx: ExtensionCommandContext): Promise<void> { - const { execSync } = await import("node:child_process"); - - const NPM_PACKAGE = "sf-run"; - const current = process.env.SF_VERSION || "0.0.0"; - - ctx.ui.notify(`Current version: v${current}\nChecking npm registry...`, "info"); - - const latest = await fetchLatestVersionForCommand(); - if (!latest) { - ctx.ui.notify("Failed to reach npm registry. Check your network connection.", "error"); - return; - } - - if (compareSemverLocal(latest, current) <= 0) { - ctx.ui.notify(`Already up to date (v${current}).`, "info"); - return; - } - - ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info"); - - const installCmd = resolveInstallCommand(`${NPM_PACKAGE}@latest`); - try { - execSync(installCmd, { - stdio: ["ignore", "pipe", "ignore"], - }); - ctx.ui.notify( - `Updated to v${latest}. Restart your SF session to use the new version.`, - "info", - ); - } catch { - ctx.ui.notify( - `Update failed. Try manually: ${installCmd}`, - "error", - ); - } -} diff --git a/src/resources/extensions/gsd/commands-inspect.ts b/src/resources/extensions/gsd/commands-inspect.ts deleted file mode 100644 index fc564a5e5..000000000 --- a/src/resources/extensions/gsd/commands-inspect.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * SF Inspect — SQLite DB diagnostics. - * - * Contains: InspectData type, formatInspectOutput, handleInspect - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { logWarning } from "./workflow-logger.js"; -import { getErrorMessage } from "./error-utils.js"; - -export interface InspectData { - schemaVersion: number | null; - counts: { decisions: number; requirements: number; artifacts: number }; - recentDecisions: Array<{ id: string; decision: string; choice: string }>; - recentRequirements: Array<{ id: string; status: string; description: string }>; -} - -export function formatInspectOutput(data: InspectData): string { - const lines: string[] = []; - lines.push("=== SF Database Inspect ==="); - lines.push(`Schema version: ${data.schemaVersion ?? "unknown"}`); - lines.push(""); - lines.push(`Decisions: ${data.counts.decisions}`); - lines.push(`Requirements: ${data.counts.requirements}`); - lines.push(`Artifacts: ${data.counts.artifacts}`); - - if (data.recentDecisions.length > 0) { - lines.push(""); - lines.push("Recent decisions:"); - for (const d of data.recentDecisions) { - lines.push(` ${d.id}: ${d.decision} → ${d.choice}`); - } - } - - if (data.recentRequirements.length > 0) { - lines.push(""); - lines.push("Recent requirements:"); - for (const r of data.recentRequirements) { - lines.push(` ${r.id} [${r.status}]: ${r.description}`); - } - } - - return lines.join("\n"); -} - -export async function handleInspect(ctx: ExtensionCommandContext): Promise<void> { - try { - const { isDbAvailable, _getAdapter, openDatabase } = await import("./gsd-db.js"); - - if (!isDbAvailable()) { - const gsdDir = gsdRoot(process.cwd()); - const dbPath = join(gsdDir, "gsd.db"); - if (!existsSync(gsdDir) || !existsSync(dbPath) || !openDatabase(dbPath)) { - ctx.ui.notify("No SF database available. Run /gsd auto to create one.", "info"); - return; - } - } - - const adapter = _getAdapter(); - if (!adapter) { - ctx.ui.notify("No SF database available. Run /gsd auto to create one.", "info"); - return; - } - - const versionRow = adapter.prepare("SELECT MAX(version) as v FROM schema_version").get(); - const schemaVersion = versionRow ? (versionRow["v"] as number | null) : null; - - const dCount = adapter.prepare("SELECT count(*) as cnt FROM decisions").get(); - const rCount = adapter.prepare("SELECT count(*) as cnt FROM requirements").get(); - const aCount = adapter.prepare("SELECT count(*) as cnt FROM artifacts").get(); - - const recentDecisions = adapter - .prepare("SELECT id, decision, choice FROM decisions ORDER BY seq DESC LIMIT 5") - .all() as Array<{ id: string; decision: string; choice: string }>; - - const recentRequirements = adapter - .prepare("SELECT id, status, description FROM requirements ORDER BY id DESC LIMIT 5") - .all() as Array<{ id: string; status: string; description: string }>; - - const data: InspectData = { - schemaVersion, - counts: { - decisions: (dCount?.["cnt"] as number) ?? 0, - requirements: (rCount?.["cnt"] as number) ?? 0, - artifacts: (aCount?.["cnt"] as number) ?? 0, - }, - recentDecisions, - recentRequirements, - }; - - ctx.ui.notify(formatInspectOutput(data), "info"); - } catch (err) { - logWarning("command", `/gsd inspect failed: ${getErrorMessage(err)}`); - ctx.ui.notify("Failed to inspect SF database. Check stderr for details.", "error"); - } -} diff --git a/src/resources/extensions/gsd/commands-logs.ts b/src/resources/extensions/gsd/commands-logs.ts deleted file mode 100644 index 6986c0ba0..000000000 --- a/src/resources/extensions/gsd/commands-logs.ts +++ /dev/null @@ -1,536 +0,0 @@ -/** - * /gsd logs — Browse activity logs, debug logs, and metrics. - * - * Subcommands: - * /gsd logs — List recent activity + debug logs - * /gsd logs <N> — Show summary of activity log #N - * /gsd logs debug — List debug log files - * /gsd logs debug <N> — Show debug log summary #N - * /gsd logs tail [N] — Show last N activity log entries (default 5) - * /gsd logs clear — Remove old activity and debug logs - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { loadJsonFileOrNull } from "./json-persistence.js"; - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface LogEntry { - seq: number; - filename: string; - unitType: string; - unitId: string; - size: number; - mtime: Date; -} - -interface DebugLogEntry { - filename: string; - size: number; - mtime: Date; -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function activityDir(basePath: string): string { - return join(gsdRoot(basePath), "activity"); -} - -function debugDir(basePath: string): string { - return join(gsdRoot(basePath), "debug"); -} - -function listActivityLogs(basePath: string): LogEntry[] { - const dir = activityDir(basePath); - if (!existsSync(dir)) return []; - - const entries: LogEntry[] = []; - try { - for (const f of readdirSync(dir)) { - if (!f.endsWith(".jsonl")) continue; - // Filename format: {seq}-{unitType}-{unitId}.jsonl - // unitType is lowercase-with-hyphens (e.g., "execute-task", "complete-slice") - // unitId starts with M followed by digits (e.g., "M001-S01-T01") - const match = f.match(/^(\d+)-([\w-]+?)-(M\d[\w-]*)\.jsonl$/); - if (!match) continue; - - const filePath = join(dir, f); - let stat; - try { stat = statSync(filePath); } catch { continue; } - - entries.push({ - seq: parseInt(match[1], 10), - filename: f, - unitType: match[2], - unitId: match[3].replace(/-/g, "/"), - size: stat.size, - mtime: stat.mtime, - }); - } - } catch { /* dir not readable */ } - - return entries.sort((a, b) => a.seq - b.seq); -} - -function listDebugLogs(basePath: string): DebugLogEntry[] { - const dir = debugDir(basePath); - if (!existsSync(dir)) return []; - - const entries: DebugLogEntry[] = []; - try { - for (const f of readdirSync(dir)) { - if (!f.endsWith(".log")) continue; - const filePath = join(dir, f); - let stat; - try { stat = statSync(filePath); } catch { continue; } - entries.push({ filename: f, size: stat.size, mtime: stat.mtime }); - } - } catch { /* dir not readable */ } - - return entries.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); -} - -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} - -function formatAge(date: Date): string { - const ms = Date.now() - date.getTime(); - const mins = Math.floor(ms / 60_000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -/** - * Extract a summary from an activity log JSONL file. - * Parses the entries to count tool calls, errors, and extract key events. - */ -function summarizeActivityLog(filePath: string): { - toolCalls: number; - errors: number; - filesWritten: string[]; - commandsRun: Array<{ command: string; failed: boolean }>; - lastReasoning: string; - entryCount: number; -} { - const result = { - toolCalls: 0, - errors: 0, - filesWritten: new Set<string>(), - commandsRun: [] as Array<{ command: string; failed: boolean }>, - lastReasoning: "", - entryCount: 0, - }; - - let raw: string; - try { raw = readFileSync(filePath, "utf-8"); } catch { return { ...result, filesWritten: [] }; } - - const lines = raw.split("\n").filter(l => l.trim()); - result.entryCount = lines.length; - - for (const line of lines) { - let entry: Record<string, unknown>; - try { entry = JSON.parse(line); } catch { continue; } - - // Count tool calls - if (entry.type === "toolCall" || (entry.role === "assistant" && entry.content && Array.isArray(entry.content))) { - if (entry.type === "toolCall") { - result.toolCalls++; - const name = entry.name as string | undefined; - const args = entry.arguments as Record<string, unknown> | undefined; - - if (name === "write" || name === "edit") { - const path = args?.file_path as string | undefined; - if (path) result.filesWritten.add(path); - } - if (name === "bash") { - const cmd = args?.command as string | undefined; - if (cmd) result.commandsRun.push({ command: cmd.slice(0, 80), failed: false }); - } - } - } - - // Count errors - if (entry.role === "toolResult" && entry.isError) { - result.errors++; - // Mark last command as failed - if (result.commandsRun.length > 0) { - result.commandsRun[result.commandsRun.length - 1].failed = true; - } - } - - // Track assistant reasoning - if (entry.role === "assistant" && typeof entry.content === "string") { - result.lastReasoning = entry.content.slice(0, 200); - } - } - - return { - ...result, - filesWritten: [...result.filesWritten], - }; -} - -/** - * Extract summary events from a debug log file. - */ -function summarizeDebugLog(filePath: string): { - events: number; - duration: string; - dispatches: number; - errors: Array<{ event: string; message: string }>; -} { - const result = { - events: 0, - duration: "unknown", - dispatches: 0, - errors: [] as Array<{ event: string; message: string }>, - }; - - let raw: string; - try { raw = readFileSync(filePath, "utf-8"); } catch { return result; } - - const lines = raw.split("\n").filter(l => l.trim()); - result.events = lines.length; - - let firstTs = 0; - let lastTs = 0; - - for (const line of lines) { - let entry: Record<string, unknown>; - try { entry = JSON.parse(line); } catch { continue; } - - const ts = entry.ts as string | undefined; - if (ts) { - const t = new Date(ts).getTime(); - if (!firstTs) firstTs = t; - lastTs = t; - } - - const event = entry.event as string | undefined; - if (!event) continue; - - if (event === "debug-summary") { - result.dispatches = (entry.dispatches as number) ?? 0; - } - - if (event.includes("error") || event.includes("failed")) { - const msg = (entry.error as string) ?? (entry.message as string) ?? JSON.stringify(entry).slice(0, 100); - result.errors.push({ event, message: msg }); - } - } - - if (firstTs && lastTs) { - const elapsed = lastTs - firstTs; - const mins = Math.floor(elapsed / 60_000); - if (mins < 1) result.duration = `${Math.floor(elapsed / 1000)}s`; - else if (mins < 60) result.duration = `${mins}m`; - else result.duration = `${Math.floor(mins / 60)}h ${mins % 60}m`; - } - - return result; -} - -// ─── Main Handler ─────────────────────────────────────────────────────────── - -export async function handleLogs(args: string, ctx: ExtensionCommandContext): Promise<void> { - const basePath = process.cwd(); - const parts = args.trim().split(/\s+/).filter(Boolean); - const subCmd = parts[0] ?? ""; - - // /gsd logs clear - if (subCmd === "clear") { - await handleLogsClear(basePath, ctx); - return; - } - - // /gsd logs debug [N] - if (subCmd === "debug") { - const idx = parts[1] ? parseInt(parts[1], 10) : undefined; - await handleLogsDebug(basePath, ctx, idx); - return; - } - - // /gsd logs tail [N] - if (subCmd === "tail") { - const count = parts[1] ? parseInt(parts[1], 10) : 5; - await handleLogsTail(basePath, ctx, count); - return; - } - - // /gsd logs <N> — show specific activity log - if (subCmd && /^\d+$/.test(subCmd)) { - const seq = parseInt(subCmd, 10); - await handleLogsShow(basePath, ctx, seq); - return; - } - - // /gsd logs — list overview - await handleLogsList(basePath, ctx); -} - -// ─── Subcommand Handlers ──────────────────────────────────────────────────── - -async function handleLogsList(basePath: string, ctx: ExtensionCommandContext): Promise<void> { - const activities = listActivityLogs(basePath); - const debugLogs = listDebugLogs(basePath); - - if (activities.length === 0 && debugLogs.length === 0) { - ctx.ui.notify( - "No logs found.\n\nActivity logs are created during auto-mode.\nDebug logs require SF_DEBUG=1.", - "info", - ); - return; - } - - const lines: string[] = []; - - if (activities.length > 0) { - lines.push("Activity Logs (.gsd/activity/):"); - lines.push(" # Unit Type Unit ID Size Age"); - lines.push(" " + "─".repeat(70)); - - // Show last 15 entries - const recent = activities.slice(-15); - for (const e of recent) { - const seq = String(e.seq).padStart(3, " "); - const type = e.unitType.padEnd(18, " "); - const id = e.unitId.padEnd(20, " "); - const size = formatSize(e.size).padStart(7, " "); - const age = formatAge(e.mtime); - lines.push(` ${seq} ${type} ${id} ${size} ${age}`); - } - - if (activities.length > 15) { - lines.push(` ... and ${activities.length - 15} older entries`); - } - lines.push(""); - lines.push(" View details: /gsd logs <#>"); - } - - if (debugLogs.length > 0) { - lines.push(""); - lines.push("Debug Logs (.gsd/debug/):"); - for (let i = 0; i < debugLogs.length; i++) { - const d = debugLogs[i]; - const size = formatSize(d.size).padStart(7, " "); - const age = formatAge(d.mtime); - lines.push(` ${i + 1}. ${d.filename} ${size} ${age}`); - } - lines.push(""); - lines.push(" View details: /gsd logs debug <#>"); - } - - // Metrics summary - const metricsPath = join(gsdRoot(basePath), "metrics.json"); - const isMetrics = (d: unknown): d is { units: Array<Record<string, unknown>> } => - d !== null && typeof d === "object" && "units" in d! && Array.isArray((d as Record<string, unknown>).units); - const metrics = loadJsonFileOrNull(metricsPath, isMetrics); - if (metrics && metrics.units.length > 0) { - const units = metrics.units; - const totalCost = units.reduce((sum: number, u) => sum + ((u.cost as number) ?? 0), 0); - const totalTokens = units.reduce((sum: number, u) => { - const t = u.tokens as Record<string, number> | undefined; - return sum + (t?.total ?? 0); - }, 0); - lines.push(""); - lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`); - } - - lines.push(""); - lines.push("Tip: Enable debug logging with SF_DEBUG=1 before /gsd auto"); - - ctx.ui.notify(lines.join("\n"), "info"); -} - -async function handleLogsShow(basePath: string, ctx: ExtensionCommandContext, seq: number): Promise<void> { - const activities = listActivityLogs(basePath); - const entry = activities.find(e => e.seq === seq); - - if (!entry) { - ctx.ui.notify(`Activity log #${seq} not found. Run /gsd logs to see available logs.`, "warning"); - return; - } - - const filePath = join(activityDir(basePath), entry.filename); - const summary = summarizeActivityLog(filePath); - - const lines: string[] = []; - lines.push(`Activity Log #${entry.seq}: ${entry.unitType} — ${entry.unitId}`); - lines.push("─".repeat(60)); - lines.push(`File: ${entry.filename}`); - lines.push(`Size: ${formatSize(entry.size)} | Age: ${formatAge(entry.mtime)}`); - lines.push(`Entries: ${summary.entryCount} | Tool calls: ${summary.toolCalls} | Errors: ${summary.errors}`); - - if (summary.filesWritten.length > 0) { - lines.push(""); - lines.push("Files written/edited:"); - for (const f of summary.filesWritten.slice(0, 10)) { - lines.push(` ${f}`); - } - if (summary.filesWritten.length > 10) { - lines.push(` ... and ${summary.filesWritten.length - 10} more`); - } - } - - if (summary.commandsRun.length > 0) { - lines.push(""); - lines.push("Commands run:"); - for (const c of summary.commandsRun.slice(0, 10)) { - const status = c.failed ? " FAILED" : ""; - lines.push(` ${c.command}${status}`); - } - if (summary.commandsRun.length > 10) { - lines.push(` ... and ${summary.commandsRun.length - 10} more`); - } - } - - if (summary.errors > 0) { - lines.push(""); - lines.push(`${summary.errors} error(s) encountered during this unit.`); - } - - if (summary.lastReasoning) { - lines.push(""); - lines.push("Last reasoning:"); - lines.push(` "${summary.lastReasoning}${summary.lastReasoning.length >= 200 ? "..." : ""}"`); - } - - lines.push(""); - lines.push(`Full log: ${filePath}`); - - ctx.ui.notify(lines.join("\n"), "info"); -} - -async function handleLogsDebug(basePath: string, ctx: ExtensionCommandContext, idx?: number): Promise<void> { - const debugLogs = listDebugLogs(basePath); - - if (debugLogs.length === 0) { - ctx.ui.notify( - "No debug logs found.\n\nEnable debug logging: SF_DEBUG=1 gsd auto", - "info", - ); - return; - } - - if (idx === undefined) { - // List debug logs - const lines: string[] = ["Debug Logs (.gsd/debug/):", ""]; - for (let i = 0; i < debugLogs.length; i++) { - const d = debugLogs[i]; - lines.push(` ${i + 1}. ${d.filename} ${formatSize(d.size)} ${formatAge(d.mtime)}`); - } - lines.push(""); - lines.push("View details: /gsd logs debug <#>"); - ctx.ui.notify(lines.join("\n"), "info"); - return; - } - - // Show specific debug log - if (idx < 1 || idx > debugLogs.length) { - ctx.ui.notify(`Debug log #${idx} not found. Available: 1-${debugLogs.length}`, "warning"); - return; - } - - const entry = debugLogs[idx - 1]; - const filePath = join(debugDir(basePath), entry.filename); - const summary = summarizeDebugLog(filePath); - - const lines: string[] = []; - lines.push(`Debug Log: ${entry.filename}`); - lines.push("─".repeat(60)); - lines.push(`Size: ${formatSize(entry.size)} | Age: ${formatAge(entry.mtime)}`); - lines.push(`Events: ${summary.events} | Duration: ${summary.duration} | Dispatches: ${summary.dispatches}`); - - if (summary.errors.length > 0) { - lines.push(""); - lines.push("Errors/failures:"); - for (const e of summary.errors.slice(0, 10)) { - lines.push(` [${e.event}] ${e.message}`); - } - if (summary.errors.length > 10) { - lines.push(` ... and ${summary.errors.length - 10} more`); - } - } - - lines.push(""); - lines.push(`Full log: ${filePath}`); - - ctx.ui.notify(lines.join("\n"), "info"); -} - -async function handleLogsTail(basePath: string, ctx: ExtensionCommandContext, count: number): Promise<void> { - const activities = listActivityLogs(basePath); - - if (activities.length === 0) { - ctx.ui.notify("No activity logs found. Logs are created during auto-mode.", "info"); - return; - } - - const recent = activities.slice(-Math.max(1, Math.min(count, 20))); - const lines: string[] = [`Last ${recent.length} activity log(s):`, ""]; - - for (const e of recent) { - const filePath = join(activityDir(basePath), e.filename); - const summary = summarizeActivityLog(filePath); - const status = summary.errors > 0 ? `${summary.errors} err` : "ok"; - lines.push(` #${e.seq} ${e.unitType} ${e.unitId} — ${summary.toolCalls} tools, ${status}, ${formatAge(e.mtime)}`); - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -async function handleLogsClear(basePath: string, ctx: ExtensionCommandContext): Promise<void> { - let removedActivity = 0; - let removedDebug = 0; - - // Clear activity logs older than 7 days, keep the 5 most recent - const activities = listActivityLogs(basePath); - const keepRecent = activities.slice(-5); - const keepSeqs = new Set(keepRecent.map(e => e.seq)); - const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; - - for (const e of activities) { - if (keepSeqs.has(e.seq)) continue; - if (e.mtime.getTime() < cutoff) { - try { - unlinkSync(join(activityDir(basePath), e.filename)); - removedActivity++; - } catch { /* ignore */ } - } - } - - // Clear debug logs older than 3 days, keep latest 2 - const debugLogs = listDebugLogs(basePath); - const keepDebug = debugLogs.slice(-2); - const keepDebugNames = new Set(keepDebug.map(d => d.filename)); - const debugCutoff = Date.now() - 3 * 24 * 60 * 60 * 1000; - - for (const d of debugLogs) { - if (keepDebugNames.has(d.filename)) continue; - if (d.mtime.getTime() < debugCutoff) { - try { - unlinkSync(join(debugDir(basePath), d.filename)); - removedDebug++; - } catch { /* ignore */ } - } - } - - if (removedActivity === 0 && removedDebug === 0) { - ctx.ui.notify("No old logs to clear.", "info"); - } else { - ctx.ui.notify( - `Cleared ${removedActivity} activity log(s) and ${removedDebug} debug log(s).`, - "info", - ); - } -} diff --git a/src/resources/extensions/gsd/commands-maintenance.ts b/src/resources/extensions/gsd/commands-maintenance.ts deleted file mode 100644 index d711b028f..000000000 --- a/src/resources/extensions/gsd/commands-maintenance.ts +++ /dev/null @@ -1,544 +0,0 @@ -/** - * SF Maintenance — cleanup, skip, dry-run, and recover handlers. - * - * Contains: handleCleanupBranches, handleCleanupSnapshots, handleCleanupWorktrees, handleSkip, handleDryRun, handleRecover - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { deriveState } from "./state.js"; -import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; -import { logWarning } from "./workflow-logger.js"; - -export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise<void> { - let branches: string[]; - try { - branches = nativeBranchList(basePath, "gsd/*"); - } catch (e) { - logWarning("command", `branch list failed: ${(e as Error).message}`); - ctx.ui.notify("No SF branches to clean up.", "info"); - return; - } - - const quickBranches = branches.filter((b) => b.startsWith("gsd/quick/")); - - const mainBranch = nativeDetectMainBranch(basePath); - let merged: string[]; - try { - merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*"); - } catch (e) { - logWarning("command", `merged branch list failed: ${(e as Error).message}`); - merged = []; - } - - const mergedNonQuick = merged.filter((b) => !b.startsWith("gsd/quick/")); - let deletedMerged = 0; - for (const branch of mergedNonQuick) { - try { - nativeBranchDelete(basePath, branch, false); - deletedMerged++; - } catch (e) { - logWarning("command", `branch delete failed for ${branch}: ${(e as Error).message}`); - } - } - - // Also delete stale milestone branches for completed milestones when detached - // from any registered worktree. - let deletedStaleMilestones = 0; - try { - const { listWorktrees } = await import("./worktree-manager.js"); - const { resolveMilestoneFile } = await import("./paths.js"); - const { loadFile } = await import("./files.js"); - const { parseRoadmap } = await import("./parsers-legacy.js"); - const { isMilestoneComplete } = await import("./state.js"); - const { isDbAvailable, getMilestone } = await import("./gsd-db.js"); - - const attachedBranches = new Set( - listWorktrees(basePath).map((wt) => wt.branch), - ); - const milestoneBranches = nativeBranchList(basePath, "milestone/*"); - for (const branch of milestoneBranches) { - if (attachedBranches.has(branch)) continue; - const milestoneId = branch.replace(/^milestone\//, ""); - - // DB-first: check milestone status directly - if (isDbAvailable()) { - const dbRow = getMilestone(milestoneId); - if (dbRow) { - if (dbRow.status !== "complete" && dbRow.status !== "done") continue; - // Milestone is complete per DB — proceed to delete branch - try { - nativeBranchDelete(basePath, branch, true); - deletedStaleMilestones++; - } catch (e) { logWarning("command", `stale milestone branch delete failed for ${branch}: ${(e as Error).message}`); } - continue; - } - } - - // Filesystem fallback - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - if (!roadmapPath) continue; - let roadmapContent: string | null = null; - try { - roadmapContent = await loadFile(roadmapPath); - } catch (e) { - logWarning("command", `loadFile failed for ${roadmapPath}: ${(e as Error).message}`); - roadmapContent = null; - } - if (!roadmapContent) continue; - if (!isMilestoneComplete(parseRoadmap(roadmapContent))) continue; - try { - nativeBranchDelete(basePath, branch, true); - deletedStaleMilestones++; - } catch (e) { - logWarning("command", `milestone branch delete failed for ${branch}: ${(e as Error).message}`); - } - } - } catch (e) { - logWarning("command", `stale milestone cleanup failed: ${(e as Error).message}`); - } - - const summary: string[] = []; - if (deletedMerged > 0) { - summary.push(`Cleaned up ${deletedMerged} merged branch${deletedMerged === 1 ? "" : "es"}.`); - } - if (deletedStaleMilestones > 0) { - summary.push(`Deleted ${deletedStaleMilestones} stale milestone branch${deletedStaleMilestones === 1 ? "" : "es"}.`); - } - if (quickBranches.length > 0) { - summary.push(`Skipped ${quickBranches.length} quick branch${quickBranches.length === 1 ? "" : "es"} (gsd/quick/*).`); - } - - if (summary.length === 0) { - const nonQuickCount = branches.filter((b) => !b.startsWith("gsd/quick/")).length; - ctx.ui.notify( - nonQuickCount > 0 - ? `${nonQuickCount} SF branch${nonQuickCount === 1 ? "" : "es"} found, none merged into ${mainBranch} yet.` - : "No non-quick SF branches to clean up.", - "info", - ); - return; - } - - ctx.ui.notify(summary.join(" "), "success"); -} - -export async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise<void> { - let refs: string[]; - try { - refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); - } catch (e) { - logWarning("command", `snapshot ref list failed: ${(e as Error).message}`); - ctx.ui.notify("No snapshot refs to clean up.", "info"); - return; - } - - if (refs.length === 0) { - ctx.ui.notify("No snapshot refs to clean up.", "info"); - return; - } - - const byLabel = new Map<string, string[]>(); - for (const ref of refs) { - const parts = ref.split("/"); - const label = parts.slice(0, -1).join("/"); - if (!byLabel.has(label)) byLabel.set(label, []); - byLabel.get(label)!.push(ref); - } - - let pruned = 0; - for (const [, labelRefs] of byLabel) { - const sorted = labelRefs.sort(); - for (const old of sorted.slice(0, -5)) { - try { - nativeUpdateRef(basePath, old); - pruned++; - } catch (e) { - logWarning("command", `snapshot ref update failed for ${old}: ${(e as Error).message}`); - } - } - } - - ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); -} - -export async function handleCleanupWorktrees(ctx: ExtensionCommandContext, basePath: string): Promise<void> { - const { getAllWorktreeHealth, formatWorktreeStatusLine } = await import("./worktree-health.js"); - const { removeWorktree } = await import("./worktree-manager.js"); - const { sep } = await import("node:path"); - - let statuses; - try { - statuses = getAllWorktreeHealth(basePath); - } catch (e) { - logWarning("command", `worktree health inspection failed: ${(e as Error).message}`); - ctx.ui.notify("Failed to inspect worktrees.", "error"); - return; - } - - if (statuses.length === 0) { - ctx.ui.notify("No SF worktrees found.", "info"); - return; - } - - const safeToRemove = statuses.filter(s => s.safeToRemove); - const stale = statuses.filter(s => s.stale && !s.safeToRemove); - const active = statuses.filter(s => !s.safeToRemove && !s.stale); - - const lines: string[] = []; - lines.push(`${statuses.length} worktree${statuses.length === 1 ? "" : "s"} found.`); - lines.push(""); - - if (safeToRemove.length > 0) { - lines.push(`Safe to remove (${safeToRemove.length}) — merged into main, clean:`); - const cwd = process.cwd(); - let removed = 0; - for (const s of safeToRemove) { - const wt = s.worktree; - const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); - if (isCwd) { - lines.push(` ⊘ ${wt.name} (skipped — current working directory)`); - continue; - } - try { - removeWorktree(basePath, wt.name, { deleteBranch: true }); - lines.push(` ✓ ${wt.name} removed (branch ${wt.branch} deleted)`); - removed++; - } catch (e) { - logWarning("command", `worktree removal failed for ${wt.name}: ${(e as Error).message}`); - lines.push(` ✗ ${wt.name} failed to remove`); - } - } - if (removed > 0) { - lines.push(""); - lines.push(`Removed ${removed} merged worktree${removed === 1 ? "" : "s"}.`); - } - lines.push(""); - } - - if (stale.length > 0) { - lines.push(`Stale (${stale.length}) — no recent commits, not merged (review manually):`); - for (const s of stale) { - lines.push(` ⚠ ${s.worktree.name} ${formatWorktreeStatusLine(s)}`); - } - lines.push(""); - } - - if (active.length > 0) { - lines.push(`Active (${active.length}) — in progress:`); - for (const s of active) { - lines.push(` ● ${s.worktree.name} ${formatWorktreeStatusLine(s)}`); - } - lines.push(""); - } - - if (safeToRemove.length === 0 && stale.length === 0) { - lines.push("All worktrees are active — nothing to clean up."); - } - - ctx.ui.notify(lines.join("\n"), safeToRemove.length > 0 ? "success" : "info"); -} - -export async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> { - if (!unitArg) { - ctx.ui.notify("Usage: /gsd skip <unit-id> (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info"); - return; - } - - const { existsSync: fileExists, writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readFile } = await import("node:fs"); - const { join: pathJoin } = await import("node:path"); - - const completedKeysFile = pathJoin(basePath, ".gsd", "completed-units.json"); - let keys: string[] = []; - try { - if (fileExists(completedKeysFile)) { - keys = JSON.parse(readFile(completedKeysFile, "utf-8")); - } - } catch (e) { logWarning("command", `completed-units.json parse failed: ${(e as Error).message}`); } - - // Normalize: accept "execute-task/M001/S01/T03", "M001/S01/T03", or just "T03" - let skipKey = unitArg; - - if (!skipKey.includes("execute-task") && !skipKey.includes("plan-") && !skipKey.includes("research-") && !skipKey.includes("complete-")) { - const state = await deriveState(basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; - - if (unitArg.match(/^T\d+$/i) && mid && sid) { - skipKey = `execute-task/${mid}/${sid}/${unitArg.toUpperCase()}`; - } else if (unitArg.match(/^S\d+$/i) && mid) { - skipKey = `plan-slice/${mid}/${unitArg.toUpperCase()}`; - } else if (unitArg.includes("/")) { - skipKey = `execute-task/${unitArg}`; - } - } - - if (keys.includes(skipKey)) { - ctx.ui.notify(`Already skipped: ${skipKey}`, "info"); - return; - } - - keys.push(skipKey); - mkDir(pathJoin(basePath, ".gsd"), { recursive: true }); - writeFile(completedKeysFile, JSON.stringify(keys), "utf-8"); - - ctx.ui.notify(`Skipped: ${skipKey}. Will not be dispatched in auto-mode.`, "success"); -} - -export async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Promise<void> { - const state = await deriveState(basePath); - - if (!state.activeMilestone) { - ctx.ui.notify("No active milestone — nothing to dispatch.", "info"); - return; - } - - const { getLedger, getProjectTotals, formatCost, formatTokenCount, loadLedgerFromDisk } = await import("./metrics.js"); - const { loadEffectiveGSDPreferences: loadPrefs } = await import("./preferences.js"); - const { formatDuration } = await import("../shared/format-utils.js"); - - const ledger = getLedger(); - const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? []; - const prefs = loadPrefs()?.preferences; - - let nextType = "unknown"; - let nextId = "unknown"; - - const mid = state.activeMilestone.id; - const midTitle = state.activeMilestone.title; - - if (state.phase === "pre-planning") { - nextType = "research-milestone"; - nextId = mid; - } else if (state.phase === "planning" && state.activeSlice) { - nextType = "plan-slice"; - nextId = `${mid}/${state.activeSlice.id}`; - } else if (state.phase === "executing" && state.activeTask && state.activeSlice) { - nextType = "execute-task"; - nextId = `${mid}/${state.activeSlice.id}/${state.activeTask.id}`; - } else if (state.phase === "summarizing" && state.activeSlice) { - nextType = "complete-slice"; - nextId = `${mid}/${state.activeSlice.id}`; - } else if (state.phase === "completing-milestone") { - nextType = "complete-milestone"; - nextId = mid; - } else { - nextType = state.phase; - nextId = mid; - } - - const sameTypeUnits = units.filter(u => u.type === nextType); - const avgCost = sameTypeUnits.length > 0 - ? sameTypeUnits.reduce((s, u) => s + u.cost, 0) / sameTypeUnits.length - : null; - const avgDuration = sameTypeUnits.length > 0 - ? sameTypeUnits.reduce((s, u) => s + (u.finishedAt - u.startedAt), 0) / sameTypeUnits.length - : null; - - const totals = units.length > 0 ? getProjectTotals(units) : null; - const budgetRemaining = prefs?.budget_ceiling && totals - ? prefs.budget_ceiling - totals.cost - : null; - - const lines = [ - `Dry-run preview:`, - ``, - ` Next unit: ${nextType}`, - ` ID: ${nextId}`, - ` Milestone: ${mid}: ${midTitle}`, - ` Phase: ${state.phase}`, - ` Est. cost: ${avgCost !== null ? `${formatCost(avgCost)} (avg of ${sameTypeUnits.length} similar)` : "unknown (first of this type)"}`, - ` Est. duration: ${avgDuration !== null ? formatDuration(avgDuration) : "unknown"}`, - ` Spent so far: ${totals ? formatCost(totals.cost) : "$0"}`, - ` Budget left: ${budgetRemaining !== null ? formatCost(budgetRemaining) : "no ceiling set"}`, - ]; - - if (state.progress) { - const p = state.progress; - lines.push(` Progress: ${p.tasks?.done ?? 0}/${p.tasks?.total ?? "?"} tasks, ${p.slices?.done ?? 0}/${p.slices?.total ?? "?"} slices`); - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -export async function handleCleanupProjects(args: string, ctx: ExtensionCommandContext): Promise<void> { - const { readdirSync, existsSync: fsExists, rmSync: fsRmSync } = await import("node:fs"); - const { join: pathJoin } = await import("node:path"); - const { readRepoMeta, externalProjectsRoot } = await import("./repo-identity.js"); - - const fix = args.includes("--fix"); - const projectsDir = externalProjectsRoot(); - - if (!fsExists(projectsDir)) { - ctx.ui.notify(`No project-state directory found at ${projectsDir} — nothing to clean up.`, "info"); - return; - } - - let hashList: string[]; - try { - hashList = readdirSync(projectsDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch (e) { - logWarning("command", `readdir failed for project-state directory: ${(e as Error).message}`); - ctx.ui.notify(`Failed to read project-state directory at ${projectsDir}.`, "error"); - return; - } - - if (hashList.length === 0) { - ctx.ui.notify(`Project-state directory is empty (${projectsDir}) — nothing to clean up.`, "info"); - return; - } - - type ProjectEntry = { hash: string; gitRoot: string; remoteUrl: string }; - const active: ProjectEntry[] = []; - const orphaned: ProjectEntry[] = []; - const unknown: string[] = []; - - for (const hash of hashList) { - const dirPath = pathJoin(projectsDir, hash); - const meta = readRepoMeta(dirPath); - if (!meta) { - unknown.push(hash); - continue; - } - const entry: ProjectEntry = { hash, gitRoot: meta.gitRoot, remoteUrl: meta.remoteUrl }; - if (fsExists(meta.gitRoot)) { - active.push(entry); - } else { - orphaned.push(entry); - } - } - - const pl = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; - const lines: string[] = [ - `${projectsDir} ${pl(hashList.length, "project state director")}${hashList.length === 1 ? "y" : "ies"}`, - "", - ]; - - if (active.length > 0) { - lines.push(`Active (${active.length}) — git root present on disk:`); - for (const e of active) { - const remote = e.remoteUrl ? ` [${e.remoteUrl}]` : ""; - lines.push(` + ${e.hash} ${e.gitRoot}${remote}`); - } - lines.push(""); - } - - if (orphaned.length > 0) { - lines.push(`Orphaned (${orphaned.length}) — git root no longer exists:`); - for (const e of orphaned) { - const remote = e.remoteUrl ? ` [${e.remoteUrl}]` : ""; - lines.push(` - ${e.hash} ${e.gitRoot}${remote}`); - } - lines.push(""); - } - - if (unknown.length > 0) { - lines.push(`Unknown (${unknown.length}) — no metadata yet:`); - for (const h of unknown) { - lines.push(` ? ${h} (open that project in SF once to register metadata)`); - } - lines.push(""); - } - - if (orphaned.length === 0) { - lines.push("No orphaned project state — all tracked repos are still present on disk."); - if (!fix) { - ctx.ui.notify(lines.join("\n"), "success"); - return; - } - } - - if (!fix && orphaned.length > 0) { - lines.push(`Run /gsd cleanup projects --fix to permanently delete ${pl(orphaned.length, "orphaned director")}${orphaned.length === 1 ? "y" : "ies"}.`); - ctx.ui.notify(lines.join("\n"), "warning"); - return; - } - - if (fix && orphaned.length > 0) { - let removed = 0; - const failed: string[] = []; - for (const e of orphaned) { - try { - fsRmSync(pathJoin(projectsDir, e.hash), { recursive: true, force: true }); - removed++; - } catch (err) { - logWarning("command", `project cleanup rm failed for ${e.hash}: ${(err as Error).message}`); - failed.push(e.hash); - } - } - lines.push(`Removed ${pl(removed, "orphaned director")}${removed === 1 ? "y" : "ies"}.`); - if (failed.length > 0) { - lines.push(`Failed to remove: ${failed.join(", ")}`); - } - ctx.ui.notify(lines.join("\n"), removed > 0 ? "success" : "warning"); - return; - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -/** - * `gsd recover` — Reconstruct DB hierarchy state from rendered markdown on disk. - * - * Deletes milestones, slices, and tasks table rows (preserves decisions, - * requirements, artifacts, memories), re-runs `migrateHierarchyToDb()` to - * repopulate from markdown, then calls `deriveState()` to verify sanity. - * - * Prints counts of recovered items and the resulting project phase. - */ -export async function handleRecover(ctx: ExtensionCommandContext, basePath: string): Promise<void> { - const { isDbAvailable: dbAvailable, clearEngineHierarchy, transaction: dbTransaction } = await import("./gsd-db.js"); - const { migrateHierarchyToDb } = await import("./md-importer.js"); - const { invalidateStateCache } = await import("./state.js"); - - if (!dbAvailable()) { - ctx.ui.notify("gsd recover: No database open. Run a SF command first to initialize the DB.", "error"); - return; - } - - try { - // 1. Delete + re-populate inside a single transaction for atomicity. - // clearEngineHierarchy() uses transaction() internally but transaction() - // is re-entrant, so wrapping in dbTransaction() keeps the whole - // clear+repopulate atomic. - const counts = dbTransaction(() => { - clearEngineHierarchy(); - return migrateHierarchyToDb(basePath); - }); - - // 3. Invalidate state cache so deriveState() picks up fresh DB data - invalidateStateCache(); - - // 4. Derive state to verify sanity - const state = await deriveState(basePath); - - // 5. Report - const lines = [ - `gsd recover: reconstructed hierarchy from markdown`, - ` Milestones: ${counts.milestones}`, - ` Slices: ${counts.slices}`, - ` Tasks: ${counts.tasks}`, - ``, - ` Phase: ${state.phase}`, - ]; - if (state.activeMilestone) { - lines.push(` Active: ${state.activeMilestone.id}: ${state.activeMilestone.title}`); - } - if (state.activeSlice) { - lines.push(` Slice: ${state.activeSlice.id}: ${state.activeSlice.title}`); - } - if (state.activeTask) { - lines.push(` Task: ${state.activeTask.id}: ${state.activeTask.title}`); - } - - process.stderr.write( - `gsd-recover: recovered ${counts.milestones}M/${counts.slices}S/${counts.tasks}T hierarchy\n`, - ); - ctx.ui.notify(lines.join("\n"), "success"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logWarning("command", `recover failed: ${msg}`); - ctx.ui.notify(`gsd recover failed: ${msg}`, "error"); - } -} diff --git a/src/resources/extensions/gsd/commands-mcp-status.ts b/src/resources/extensions/gsd/commands-mcp-status.ts deleted file mode 100644 index 9339929e3..000000000 --- a/src/resources/extensions/gsd/commands-mcp-status.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * MCP Status — `/gsd mcp` command handler. - * - * Shows configured MCP servers, their connection status, and available tools. - * - * Subcommands: - * /gsd mcp — Overview of all servers (alias: /gsd mcp status) - * /gsd mcp status — Same as bare /gsd mcp - * /gsd mcp check <srv> — Detailed status for a specific server - * /gsd mcp init [dir] — Write project-local SF workflow MCP config - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -import { ensureProjectWorkflowMcpConfig } from "./mcp-project-config.js"; - -// ─── Types ────────────────────────────────────────────────────────────────── - -export interface McpServerStatus { - name: string; - transport: "stdio" | "http" | "unknown"; - connected: boolean; - toolCount: number; - error: string | undefined; -} - -export interface McpServerDetail extends McpServerStatus { - tools: string[]; -} - -export function formatMcpInitResult( - status: "created" | "updated" | "unchanged", - configPath: string, - targetPath: string, -): string { - const summary = - status === "created" - ? "Created project MCP config." - : status === "updated" - ? "Updated project MCP config." - : "Project MCP config is already up to date."; - - return [ - summary, - "", - `Project: ${targetPath}`, - `Config: ${configPath}`, - "", - "Claude Code can now load the SF workflow MCP server from this folder.", - ].join("\n"); -} - -// ─── Config reader (standalone — does not import mcp-client internals) ────── - -interface McpServerRawConfig { - name: string; - transport: "stdio" | "http" | "unknown"; - command?: string; - args?: string[]; - url?: string; -} - -function readMcpConfigs(): McpServerRawConfig[] { - const servers: McpServerRawConfig[] = []; - const seen = new Set<string>(); - const configPaths = [ - join(process.cwd(), ".mcp.json"), - join(process.cwd(), ".gsd", "mcp.json"), - ]; - - for (const configPath of configPaths) { - try { - if (!existsSync(configPath)) continue; - const raw = readFileSync(configPath, "utf-8"); - const data = JSON.parse(raw) as Record<string, unknown>; - const mcpServers = (data.mcpServers ?? data.servers) as - | Record<string, Record<string, unknown>> - | undefined; - if (!mcpServers || typeof mcpServers !== "object") continue; - - for (const [name, config] of Object.entries(mcpServers)) { - if (seen.has(name)) continue; - seen.add(name); - - const hasCommand = typeof config.command === "string"; - const hasUrl = typeof config.url === "string"; - const transport: McpServerRawConfig["transport"] = hasCommand - ? "stdio" - : hasUrl - ? "http" - : "unknown"; - - servers.push({ - name, - transport, - ...(hasCommand && { - command: config.command as string, - args: Array.isArray(config.args) ? (config.args as string[]) : undefined, - }), - ...(hasUrl && { url: config.url as string }), - }); - } - } catch { - // Non-fatal — config file may not exist or be malformed - } - } - - return servers; -} - -// ─── Formatters (exported for testing) ────────────────────────────────────── - -export function formatMcpStatusReport(servers: McpServerStatus[]): string { - if (servers.length === 0) { - return [ - "No MCP servers configured.", - "", - "Add servers to .mcp.json or .gsd/mcp.json to enable MCP integrations.", - "Tip: run /gsd mcp init . to write the local SF workflow MCP config.", - "See: https://modelcontextprotocol.io/quickstart", - ].join("\n"); - } - - const lines: string[] = [`MCP Server Status — ${servers.length} server(s)\n`]; - - for (const s of servers) { - const icon = s.error ? "✗" : s.connected ? "✓" : "○"; - const status = s.error - ? `error: ${s.error}` - : s.connected - ? `connected — ${s.toolCount} tools` - : "disconnected"; - lines.push(` ${icon} ${s.name} (${s.transport}) — ${status}`); - } - - lines.push(""); - lines.push("Use /gsd mcp check <server> for details on a specific server."); - lines.push("Use mcp_discover to connect and list tools for a server."); - - return lines.join("\n"); -} - -export function formatMcpServerDetail(server: McpServerDetail): string { - const lines: string[] = [`MCP Server: ${server.name}\n`]; - - lines.push(` Transport: ${server.transport}`); - - if (server.error) { - lines.push(` Status: error`); - lines.push(` Error: ${server.error}`); - } else if (server.connected) { - lines.push(` Status: connected`); - lines.push(` Tools: ${server.toolCount}`); - if (server.tools.length > 0) { - lines.push(""); - lines.push(" Available tools:"); - for (const tool of server.tools) { - lines.push(` - ${tool}`); - } - } - } else { - lines.push(` Status: disconnected`); - lines.push(""); - lines.push(` Run mcp_discover("${server.name}") to connect and list tools.`); - } - - return lines.join("\n"); -} - -// ─── Command handler ──────────────────────────────────────────────────────── - -/** - * Handle `/gsd mcp [status|check <server>]`. - */ -export async function handleMcpStatus( - args: string, - ctx: ExtensionCommandContext, -): Promise<void> { - const trimmed = args.trim(); - const lowered = trimmed.toLowerCase(); - const configs = readMcpConfigs(); - - // /gsd mcp init [dir] - if (!lowered || lowered === "status") { - // handled below - } else if (lowered === "init" || lowered.startsWith("init ")) { - const rawPath = trimmed.slice("init".length).trim(); - const targetPath = resolve(rawPath || "."); - try { - const result = ensureProjectWorkflowMcpConfig(targetPath); - ctx.ui.notify(formatMcpInitResult(result.status, result.configPath, targetPath), "info"); - } catch (err) { - ctx.ui.notify( - `Failed to prepare MCP config for ${targetPath}: ${err instanceof Error ? err.message : String(err)}`, - "error", - ); - } - return; - } - - // /gsd mcp check <server> - if (lowered.startsWith("check ")) { - const serverName = trimmed.slice("check ".length).trim(); - const config = configs.find((c) => c.name === serverName); - if (!config) { - const available = configs.map((c) => c.name).join(", ") || "(none)"; - ctx.ui.notify( - `Unknown MCP server: "${serverName}"\n\nAvailable: ${available}`, - "warning", - ); - return; - } - - // Try to get connection/tool info from the mcp-client module if available - let connected = false; - let toolNames: string[] = []; - let error: string | undefined; - try { - const mcpClient = await import("../mcp-client/index.js"); - // Access the module's connection state if exported; fall back gracefully - const mod = mcpClient as Record<string, unknown>; - if (typeof mod.getConnectionStatus === "function") { - const status = (mod.getConnectionStatus as (name: string) => { connected: boolean; tools: string[]; error?: string })(serverName); - connected = status.connected; - toolNames = status.tools; - error = status.error; - } - } catch { - // mcp-client may not expose status helpers — that's fine - } - - ctx.ui.notify( - formatMcpServerDetail({ - name: config.name, - transport: config.transport, - connected, - toolCount: toolNames.length, - tools: toolNames, - error, - }), - "info", - ); - return; - } - - // /gsd mcp or /gsd mcp status - if (!lowered || lowered === "status") { - // Build status for each server - const statuses: McpServerStatus[] = []; - - for (const config of configs) { - let connected = false; - let toolCount = 0; - let error: string | undefined; - - try { - const mcpClient = await import("../mcp-client/index.js"); - const mod = mcpClient as Record<string, unknown>; - if (typeof mod.getConnectionStatus === "function") { - const status = (mod.getConnectionStatus as (name: string) => { connected: boolean; tools: string[]; error?: string })(config.name); - connected = status.connected; - toolCount = status.tools.length; - error = status.error; - } - } catch { - // Fall back to unknown state - } - - statuses.push({ - name: config.name, - transport: config.transport, - connected, - toolCount, - error, - }); - } - - ctx.ui.notify(formatMcpStatusReport(statuses), "info"); - return; - } - - // Unknown subcommand - ctx.ui.notify( - "Usage: /gsd mcp [status|check <server>|init [dir]]\n\n" + - " status Show all MCP server statuses (default)\n" + - " check <server> Detailed status for a specific server\n" + - " init [dir] Write .mcp.json for the local SF workflow MCP server", - "warning", - ); -} diff --git a/src/resources/extensions/gsd/commands-pr-branch.ts b/src/resources/extensions/gsd/commands-pr-branch.ts deleted file mode 100644 index 10a5caaaf..000000000 --- a/src/resources/extensions/gsd/commands-pr-branch.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * SF Command — /gsd pr-branch - * - * Creates a clean PR branch by cherry-picking commits while stripping - * any changes to .gsd/, .planning/, and PLAN.md paths. Useful for - * upstream PRs where planning artifacts should not be included. - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { execFileSync } from "node:child_process"; - -import { - nativeGetCurrentBranch, - nativeDetectMainBranch, - nativeBranchExists, -} from "./native-git-bridge.js"; - -const EXCLUDED_PATHS = [".gsd", ".planning", "PLAN.md"] as const; - -function git(basePath: string, args: readonly string[]): string { - return execFileSync("git", args, { cwd: basePath, encoding: "utf-8" }).trim(); -} - -function gitAllowFail(basePath: string, args: readonly string[]): void { - try { - execFileSync("git", args, { cwd: basePath, encoding: "utf-8", stdio: "pipe" }); - } catch { - // ignored — caller opts into non-fatal behavior - } -} - -function hasStagedChanges(basePath: string): boolean { - try { - execFileSync("git", ["diff", "--cached", "--quiet"], { - cwd: basePath, - stdio: "pipe", - }); - return false; - } catch { - return true; - } -} - -function isValidBranchName(name: string): boolean { - try { - execFileSync("git", ["check-ref-format", "--branch", name], { stdio: "pipe" }); - return true; - } catch { - return false; - } -} - -function getCodeOnlyCommits(basePath: string, base: string, head: string): string[] { - try { - const allCommits = git(basePath, ["log", "--format=%H", `${base}..${head}`]) - .split("\n") - .filter(Boolean); - const codeCommits: string[] = []; - - for (const sha of allCommits) { - const files = git(basePath, ["diff-tree", "--no-commit-id", "--name-only", "-r", sha]) - .split("\n") - .filter(Boolean); - const hasCodeChanges = files.some( - (f) => !f.startsWith(".gsd/") && !f.startsWith(".planning/") && f !== "PLAN.md", - ); - if (hasCodeChanges) { - codeCommits.push(sha); - } - } - - return codeCommits.reverse(); // chronological for cherry-picking - } catch { - return []; - } -} - -/** - * Cherry-pick a commit while stripping excluded paths from the resulting - * commit. Returns true if a commit was produced, false if nothing remained - * after filtering. - */ -function cherryPickFiltered(basePath: string, sha: string): boolean { - git(basePath, ["cherry-pick", "--no-commit", "--allow-empty", sha]); - - // Unstage any excluded paths introduced by the cherry-pick. - gitAllowFail(basePath, ["reset", "HEAD", "--", ...EXCLUDED_PATHS]); - - // Restore worktree state for excluded paths from HEAD (if tracked), - // then remove any newly introduced untracked files under those paths. - gitAllowFail(basePath, ["checkout", "HEAD", "--", ...EXCLUDED_PATHS]); - gitAllowFail(basePath, ["clean", "-fdq", "--", ...EXCLUDED_PATHS]); - - if (!hasStagedChanges(basePath)) { - // Nothing remained after filtering — discard worktree residue and skip. - git(basePath, ["reset", "--hard", "HEAD"]); - return false; - } - - git(basePath, ["commit", "-C", sha]); - return true; -} - -function assertNoExcludedPaths(basePath: string, base: string): void { - const files = git(basePath, [ - "diff", - "--name-only", - `${base}..HEAD`, - ]) - .split("\n") - .filter(Boolean); - const leaked = files.filter( - (f) => f.startsWith(".gsd/") || f.startsWith(".planning/") || f === "PLAN.md", - ); - if (leaked.length > 0) { - throw new Error( - `PR branch still contains excluded paths: ${leaked.slice(0, 5).join(", ")}${ - leaked.length > 5 ? ` (+${leaked.length - 5} more)` : "" - }`, - ); - } -} - -export async function handlePrBranch( - args: string, - ctx: ExtensionCommandContext, -): Promise<void> { - const basePath = process.cwd(); - const dryRun = args.includes("--dry-run"); - const nameMatch = args.match(/--name\s+(\S+)/); - - const currentBranch = nativeGetCurrentBranch(basePath); - const mainBranch = nativeDetectMainBranch(basePath); - - // Determine base ref (prefer upstream/main if available) - let baseRef: string; - try { - git(basePath, ["rev-parse", "--verify", "upstream/main"]); - baseRef = "upstream/main"; - } catch { - baseRef = mainBranch; - } - - // Find commits with code changes - const commits = getCodeOnlyCommits(basePath, baseRef, "HEAD"); - - if (commits.length === 0) { - ctx.ui.notify("No code-only commits found (all commits only touch .gsd/ files).", "info"); - return; - } - - if (dryRun) { - const lines = [`Would create PR branch with ${commits.length} commits (filtering .gsd/ paths):\n`]; - for (const sha of commits) { - const msg = git(basePath, ["log", "--format=%s", "-1", sha]); - lines.push(` ${sha.slice(0, 8)} ${msg}`); - } - ctx.ui.notify(lines.join("\n"), "info"); - return; - } - - const requestedName = nameMatch?.[1]; - if (requestedName && !isValidBranchName(requestedName)) { - ctx.ui.notify( - `Invalid branch name: ${requestedName}. Must satisfy git check-ref-format.`, - "error", - ); - return; - } - - const defaultName = `pr/${currentBranch}`; - const prBranch = requestedName ?? defaultName; - - if (!isValidBranchName(prBranch)) { - ctx.ui.notify( - `Derived branch name is invalid: ${prBranch}. Use --name to override.`, - "error", - ); - return; - } - - if (nativeBranchExists(basePath, prBranch)) { - ctx.ui.notify( - `Branch ${prBranch} already exists. Use --name to specify a different name, or delete it first.`, - "warning", - ); - return; - } - - try { - // Create clean branch from base - git(basePath, ["checkout", "-b", prBranch, baseRef]); - - // Cherry-pick with path filter - let picked = 0; - let skipped = 0; - for (const sha of commits) { - try { - if (cherryPickFiltered(basePath, sha)) { - picked++; - } else { - skipped++; - } - } catch (pickErr) { - gitAllowFail(basePath, ["cherry-pick", "--abort"]); - gitAllowFail(basePath, ["reset", "--hard", "HEAD"]); - const detail = pickErr instanceof Error ? pickErr.message : String(pickErr); - ctx.ui.notify( - `Cherry-pick conflict at ${sha.slice(0, 8)}. Picked ${picked}/${commits.length} commits. Resolve manually.\n${detail}`, - "warning", - ); - git(basePath, ["checkout", currentBranch]); - return; - } - } - - // Post-condition: no excluded paths should appear in the PR branch diff. - assertNoExcludedPaths(basePath, baseRef); - - const skippedMsg = skipped > 0 ? ` (${skipped} skipped — contained only planning artifacts)` : ""; - ctx.ui.notify( - `Created ${prBranch} with ${picked} commits${skippedMsg} (no .gsd/ artifacts).\nSwitch back: git checkout ${currentBranch}`, - "success", - ); - } catch (err) { - // Restore original branch on failure - gitAllowFail(basePath, ["cherry-pick", "--abort"]); - gitAllowFail(basePath, ["reset", "--hard", "HEAD"]); - gitAllowFail(basePath, ["checkout", currentBranch]); - const msg = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to create PR branch: ${msg}`, "error"); - } -} diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts deleted file mode 100644 index 6968f84ed..000000000 --- a/src/resources/extensions/gsd/commands-prefs-wizard.ts +++ /dev/null @@ -1,864 +0,0 @@ -/** - * SF Preferences Wizard — TUI wizard for configuring SF preferences. - * - * Contains: handlePrefsWizard, buildCategorySummaries, all configure* functions, - * serializePreferencesToFrontmatter, yamlSafeString, ensurePreferencesFile, - * handlePrefsMode, handleImportClaude, handlePrefs - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - getGlobalGSDPreferencesPath, - getLegacyGlobalGSDPreferencesPath, - getProjectGSDPreferencesPath, - loadGlobalGSDPreferences, - loadProjectGSDPreferences, - loadEffectiveGSDPreferences, - resolveAllSkillReferences, -} from "./preferences.js"; -import { loadFile, saveFile, splitFrontmatter, parseFrontmatterMap } from "./files.js"; -import { runClaudeImportFlow } from "./claude-import.js"; - -/** Extract body content after frontmatter closing delimiter, or null if none. */ -function extractBodyAfterFrontmatter(content: string): string | null { - const closingIdx = content.indexOf("\n---", content.indexOf("---")); - if (closingIdx === -1) return null; - const afterFrontmatter = content.slice(closingIdx + 4); - return afterFrontmatter.trim() ? afterFrontmatter : null; -} - -// ─── Numeric validation helpers ────────────────────────────────────────────── - -/** Parse a string as a non-negative integer, or return null on failure. */ -function tryParseInteger(val: string): number | null { - return /^\d+$/.test(val) ? Number(val) : null; -} - -/** Parse a string as a finite number, or return null on failure. */ -function tryParseNumber(val: string): number | null { - const n = Number(val); - return !isNaN(n) && isFinite(n) ? n : null; -} - -/** Parse a string as a number in the 0–100 range, or return null on failure. */ -function tryParsePercentage(val: string): number | null { - const n = Number(val); - return !isNaN(n) && n >= 0 && n <= 100 ? n : null; -} - -export async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> { - const trimmed = args.trim(); - - if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup" - || trimmed === "wizard global" || trimmed === "setup global") { - await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); - await handlePrefsWizard(ctx, "global"); - return; - } - - if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") { - await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project"); - await handlePrefsWizard(ctx, "project"); - return; - } - - if (trimmed === "import-claude" || trimmed === "import-claude global") { - await handleImportClaude(ctx, "global"); - return; - } - - if (trimmed === "import-claude project") { - await handleImportClaude(ctx, "project"); - return; - } - if (trimmed === "status") { - const globalPrefs = loadGlobalGSDPreferences(); - const projectPrefs = loadProjectGSDPreferences(); - const canonicalGlobal = getGlobalGSDPreferencesPath(); - const legacyGlobal = getLegacyGlobalGSDPreferencesPath(); - const globalStatus = globalPrefs - ? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}` - : `missing: ${canonicalGlobal}`; - const projectStatus = projectPrefs ? `present: ${projectPrefs.path}` : `missing: ${getProjectGSDPreferencesPath()}`; - - const lines = [`SF skill prefs — global ${globalStatus}; project ${projectStatus}`]; - - const effective = loadEffectiveGSDPreferences(); - let hasUnresolved = false; - if (effective) { - const report = resolveAllSkillReferences(effective.preferences, process.cwd()); - const resolved = [...report.resolutions.values()].filter(r => r.method !== "unresolved"); - hasUnresolved = report.warnings.length > 0; - if (resolved.length > 0 || hasUnresolved) { - lines.push(`Skills: ${resolved.length} resolved, ${report.warnings.length} unresolved`); - } - if (hasUnresolved) { - lines.push(`Unresolved: ${report.warnings.join(", ")}`); - } - } - - ctx.ui.notify(lines.join("\n"), hasUnresolved ? "warning" : "info"); - return; - } - - ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup|import-claude [global|project]]", "info"); -} - -export async function handleImportClaude(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise<void> { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - if (!existsSync(path)) { - await ensurePreferencesFile(path, ctx, scope); - } - - const readPrefs = (): Record<string, unknown> => { - if (!existsSync(path)) return { version: 1 }; - const content = readFileSync(path, "utf-8"); - const [frontmatterLines] = splitFrontmatter(content); - return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; - }; - - const writePrefs = async (prefs: Record<string, unknown>): Promise<void> => { - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - } - await saveFile(path, `---\n${frontmatter}---${body}`); - }; - - await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); -} - -export async function handlePrefsMode(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise<void> { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); - const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {}; - - await configureMode(ctx, prefs); - - // Serialize and save - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - } - - const content = `---\n${frontmatter}---${body}`; - await saveFile(path, content); - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); -} - -/** Build short summary strings for each preference category. */ -export function buildCategorySummaries(prefs: Record<string, unknown>): Record<string, string> { - // Mode - const mode = prefs.mode as string | undefined; - const modeSummary = mode ?? "(not set)"; - - // Models - const models = prefs.models as Record<string, unknown> | undefined; - let modelsSummary = "(not configured)"; - if (models && Object.keys(models).length > 0) { - const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${formatConfiguredModel(model)}`); - modelsSummary = parts.join(", "); - } - - // Timeouts - const autoSup = prefs.auto_supervisor as Record<string, unknown> | undefined; - let timeoutsSummary = "(defaults)"; - if (autoSup && Object.keys(autoSup).length > 0) { - const soft = autoSup.soft_timeout_minutes ?? "20"; - const idle = autoSup.idle_timeout_minutes ?? "10"; - const hard = autoSup.hard_timeout_minutes ?? "30"; - timeoutsSummary = `soft: ${soft}m, idle: ${idle}m, hard: ${hard}m`; - } - - // Git - const git = prefs.git as Record<string, unknown> | undefined; - const staleThreshold = prefs.stale_commit_threshold_minutes; - const absorbSnapshots = git?.absorb_snapshot_commits; - let gitSummary = "(defaults)"; - { - const parts: string[] = []; - if (git && Object.keys(git).length > 0) { - const branch = git.main_branch ?? "main"; - const push = git.auto_push ? "on" : "off"; - parts.push(`main: ${branch}, push: ${push}`); - } - if (staleThreshold !== undefined) { - parts.push(`stale: ${staleThreshold === 0 ? "off" : `${staleThreshold}m`}`); - } - if (absorbSnapshots !== undefined) { - parts.push(`absorb: ${absorbSnapshots ? "on" : "off"}`); - } - if (parts.length > 0) gitSummary = parts.join(", "); - } - - // Skills - const discovery = prefs.skill_discovery as string | undefined; - const uat = prefs.uat_dispatch; - let skillsSummary = "(not configured)"; - if (discovery || uat !== undefined) { - const parts: string[] = []; - if (discovery) parts.push(`discovery: ${discovery}`); - if (uat !== undefined) parts.push(`uat: ${uat}`); - skillsSummary = parts.join(", "); - } - - // Budget - const ceiling = prefs.budget_ceiling; - const enforcement = prefs.budget_enforcement as string | undefined; - let budgetSummary = "(no limit)"; - if (ceiling !== undefined) { - budgetSummary = `$${ceiling}`; - if (enforcement) budgetSummary += ` / ${enforcement}`; - } else if (enforcement) { - budgetSummary = enforcement; - } - - // Notifications - const notif = prefs.notifications as Record<string, boolean> | undefined; - let notifSummary = "(defaults)"; - if (notif && Object.keys(notif).length > 0) { - const allKeys = ["enabled", "on_complete", "on_error", "on_budget", "on_milestone", "on_attention"]; - const enabledCount = allKeys.filter(k => notif[k] !== false).length; - notifSummary = `${enabledCount}/${allKeys.length} enabled`; - } - - // Advanced - const uniqueIds = prefs.unique_milestone_ids; - let advancedSummary = "(defaults)"; - if (uniqueIds !== undefined) { - advancedSummary = `unique IDs: ${uniqueIds ? "on" : "off"}`; - } - - return { - mode: modeSummary, - models: modelsSummary, - timeouts: timeoutsSummary, - git: gitSummary, - skills: skillsSummary, - budget: budgetSummary, - notifications: notifSummary, - advanced: advancedSummary, - }; -} - -// ─── Category configuration functions ──────────────────────────────────────── - -export function formatConfiguredModel(config: unknown): string { - if (typeof config === "string") return config; - if (!config || typeof config !== "object") return "(invalid)"; - const maybeConfig = config as { model?: unknown; provider?: unknown }; - if (typeof maybeConfig.model !== "string" || maybeConfig.model.trim() === "") return "(invalid)"; - if (typeof maybeConfig.provider === "string" && maybeConfig.provider && !maybeConfig.model.includes("/")) { - return `${maybeConfig.provider}/${maybeConfig.model}`; - } - return maybeConfig.model; -} - -export function toPersistedModelId(provider: string, modelId: string): string { - if (!provider.trim()) return modelId; - const normalizedProvider = provider.trim(); - const normalizedModelId = modelId.trim(); - return normalizedModelId.startsWith(`${normalizedProvider}/`) - ? normalizedModelId - : `${normalizedProvider}/${normalizedModelId}`; -} - -async function configureModels(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const modelPhases = [ - "research", - "planning", - "discuss", - "execution", - "execution_simple", - "completion", - "validation", - "subagent", - ] as const; - const models: Record<string, unknown> = (prefs.models as Record<string, unknown>) ?? {}; - - const availableModels = ctx.modelRegistry.getAvailable(); - if (availableModels.length > 0) { - // Group models by provider, sorted alphabetically - const byProvider = new Map<string, typeof availableModels>(); - for (const m of availableModels) { - let group = byProvider.get(m.provider); - if (!group) { - group = []; - byProvider.set(m.provider, group); - } - group.push(m); - } - const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b)); - // Sort models within each provider - for (const group of byProvider.values()) { - group.sort((a, b) => a.id.localeCompare(b.id)); - } - - // Display names for providers in the preferences wizard UI. - const PROVIDER_DISPLAY_NAMES: Record<string, string> = { anthropic: "anthropic-api" }; - const displayName = (p: string) => PROVIDER_DISPLAY_NAMES[p] ?? p; - - // Build provider menu with model counts (display name → real name lookup) - const displayToReal = new Map<string, string>(); - const providerOptions = providers.map(p => { - const count = byProvider.get(p)!.length; - const label = `${displayName(p)} (${count} models)`; - displayToReal.set(label, p); - return label; - }); - providerOptions.push("(keep current)", "(clear)", "(type manually)"); - - for (const phase of modelPhases) { - const current = formatConfiguredModel(models[phase]); - const phaseLabel = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}`; - - // Step 1: pick provider - const providerChoice = await ctx.ui.select(`${phaseLabel} — choose provider:`, providerOptions); - if (!providerChoice || typeof providerChoice !== "string" || providerChoice === "(keep current)") continue; - - if (providerChoice === "(clear)") { - delete models[phase]; - continue; - } - - if (providerChoice === "(type manually)") { - const input = await ctx.ui.input( - `${phaseLabel} — enter model ID:`, - current || "e.g. claude-sonnet-4-20250514", - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - if (val) models[phase] = val; - } - continue; - } - - // Step 2: pick model within provider - const providerName = displayToReal.get(providerChoice) ?? providerChoice.replace(/ \(\d+ models?\)$/, ""); - const group = byProvider.get(providerName); - if (!group) continue; - - const modelOptions = group.map(m => m.id); - modelOptions.push("(keep current)", "(clear)"); - - const modelChoice = await ctx.ui.select(`${phaseLabel} — ${displayName(providerName)}:`, modelOptions); - if (modelChoice && typeof modelChoice === "string" && modelChoice !== "(keep current)") { - if (modelChoice === "(clear)") { - delete models[phase]; - } else { - models[phase] = toPersistedModelId(providerName, modelChoice); - } - } - } - } else { - for (const phase of modelPhases) { - const current = formatConfiguredModel(models[phase]); - const input = await ctx.ui.input( - `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, - current || "e.g. claude-sonnet-4-20250514", - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - if (val) { - models[phase] = val; - } else if (current) { - delete models[phase]; - } - } - } - } - if (Object.keys(models).length > 0) { - prefs.models = models; - } else { - delete prefs.models; - } -} - -async function configureTimeouts(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const autoSup: Record<string, unknown> = (prefs.auto_supervisor as Record<string, unknown>) ?? {}; - const timeoutFields = [ - { key: "soft_timeout_minutes", label: "Soft timeout (minutes)", defaultVal: "20" }, - { key: "idle_timeout_minutes", label: "Idle timeout (minutes)", defaultVal: "10" }, - { key: "hard_timeout_minutes", label: "Hard timeout (minutes)", defaultVal: "30" }, - ] as const; - - for (const field of timeoutFields) { - const current = autoSup[field.key]; - const currentStr = current !== undefined && current !== null ? String(current) : ""; - const input = await ctx.ui.input( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - currentStr || field.defaultVal, - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - const parsed = tryParseInteger(val); - if (val && parsed !== null) { - autoSup[field.key] = parsed; - } else if (val) { - ctx.ui.notify(`Invalid value "${val}" for ${field.label} — must be a whole number. Keeping previous value.`, "warning"); - } else if (!val && currentStr) { - delete autoSup[field.key]; - } - } - } - if (Object.keys(autoSup).length > 0) { - prefs.auto_supervisor = autoSup; - } -} - -async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const git: Record<string, unknown> = (prefs.git as Record<string, unknown>) ?? {}; - - // main_branch - const currentBranch = git.main_branch ? String(git.main_branch) : ""; - const branchInput = await ctx.ui.input( - `Git main branch${currentBranch ? ` (current: ${currentBranch})` : ""}:`, - currentBranch || "main", - ); - if (branchInput !== null && branchInput !== undefined) { - const val = branchInput.trim(); - if (val) { - git.main_branch = val; - } else if (currentBranch) { - delete git.main_branch; - } - } - - // Boolean git toggles - const gitBooleanFields = [ - { key: "auto_push", label: "Auto-push commits after committing", defaultVal: false }, - { key: "push_branches", label: "Push milestone branches to remote", defaultVal: false }, - { key: "snapshots", label: "Create WIP snapshot commits during long tasks", defaultVal: true }, - ] as const; - - for (const field of gitBooleanFields) { - const current = git[field.key]; - const currentStr = current !== undefined ? String(current) : ""; - const choice = await ctx.ui.select( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - ["true", "false", "(keep current)"], - ); - if (choice && choice !== "(keep current)") { - git[field.key] = choice === "true"; - } - } - - // remote - const currentRemote = git.remote ? String(git.remote) : ""; - const remoteInput = await ctx.ui.input( - `Git remote name${currentRemote ? ` (current: ${currentRemote})` : " (default: origin)"}:`, - currentRemote || "origin", - ); - if (remoteInput !== null && remoteInput !== undefined) { - const val = remoteInput.trim(); - if (val && val !== "origin") { - git.remote = val; - } else if (!val && currentRemote) { - delete git.remote; - } - } - - // pre_merge_check - const currentPreMerge = git.pre_merge_check !== undefined ? String(git.pre_merge_check) : ""; - const preMergeChoice = await ctx.ui.select( - `Pre-merge check${currentPreMerge ? ` (current: ${currentPreMerge})` : " (default: auto)"}:`, - ["true", "false", "auto", "(keep current)"], - ); - if (preMergeChoice && preMergeChoice !== "(keep current)") { - if (preMergeChoice === "auto") { - git.pre_merge_check = "auto"; - } else { - git.pre_merge_check = preMergeChoice === "true"; - } - } - - // commit_type - const currentCommitType = git.commit_type ? String(git.commit_type) : ""; - const commitTypes = ["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style", "(inferred — default)", "(keep current)"]; - const commitChoice = await ctx.ui.select( - `Default commit type${currentCommitType ? ` (current: ${currentCommitType})` : ""}:`, - commitTypes, - ); - if (commitChoice && typeof commitChoice === "string" && commitChoice !== "(keep current)") { - if ((commitChoice as string).startsWith("(inferred")) { - delete git.commit_type; - } else { - git.commit_type = commitChoice; - } - } - - // merge_strategy - const currentMerge = git.merge_strategy ? String(git.merge_strategy) : ""; - const mergeChoice = await ctx.ui.select( - `Merge strategy${currentMerge ? ` (current: ${currentMerge})` : ""}:`, - ["squash", "merge", "(keep current)"], - ); - if (mergeChoice && mergeChoice !== "(keep current)") { - git.merge_strategy = mergeChoice; - } - - // isolation - const currentIsolation = git.isolation ? String(git.isolation) : ""; - const isolationChoice = await ctx.ui.select( - `Git isolation strategy${currentIsolation ? ` (current: ${currentIsolation})` : " (default: worktree)"}:`, - ["worktree", "branch", "none", "(keep current)"], - ); - if (isolationChoice && isolationChoice !== "(keep current)") { - git.isolation = isolationChoice; - } - - // absorb_snapshot_commits (git sub-key) - const currentAbsorb = git.absorb_snapshot_commits; - const absorbStr = currentAbsorb !== undefined ? String(currentAbsorb) : ""; - const absorbChoice = await ctx.ui.select( - `Absorb snapshot commits into real commits${absorbStr ? ` (current: ${absorbStr})` : " (default: true)"}:`, - ["true", "false", "(keep current)"], - ); - if (absorbChoice && absorbChoice !== "(keep current)") { - git.absorb_snapshot_commits = absorbChoice === "true"; - } - - if (Object.keys(git).length > 0) { - prefs.git = git; - } - - // stale_commit_threshold_minutes (top-level pref, shown in Git section) - const currentThreshold = prefs.stale_commit_threshold_minutes; - const thresholdStr = currentThreshold !== undefined ? String(currentThreshold) : ""; - const thresholdInput = await ctx.ui.input( - `Stale commit threshold (minutes, 0 to disable)${thresholdStr ? ` (current: ${thresholdStr})` : " (default: 30)"}:`, - thresholdStr || "30", - ); - if (thresholdInput !== null && thresholdInput !== undefined) { - const val = thresholdInput.trim(); - const parsed = tryParseInteger(val); - if (val && parsed !== null && parsed >= 0) { - prefs.stale_commit_threshold_minutes = parsed; - } else if (val && parsed === null) { - ctx.ui.notify(`Invalid value "${val}" — must be a whole number. Keeping previous value.`, "warning"); - } else if (!val && currentThreshold !== undefined) { - delete prefs.stale_commit_threshold_minutes; - } - } -} - -async function configureSkills(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - // Skill discovery mode - const currentDiscovery = (prefs.skill_discovery as string) ?? ""; - const discoveryChoice = await ctx.ui.select( - `Skill discovery mode${currentDiscovery ? ` (current: ${currentDiscovery})` : ""}:`, - ["auto", "suggest", "off", "(keep current)"], - ); - if (discoveryChoice && discoveryChoice !== "(keep current)") { - prefs.skill_discovery = discoveryChoice; - } - - // UAT dispatch - const currentUat = prefs.uat_dispatch; - const uatChoice = await ctx.ui.select( - `UAT dispatch mode${currentUat !== undefined ? ` (current: ${currentUat})` : " (default: false)"}:`, - ["true", "false", "(keep current)"], - ); - if (uatChoice && uatChoice !== "(keep current)") { - prefs.uat_dispatch = uatChoice === "true"; - } -} - -async function configureBudget(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const currentCeiling = prefs.budget_ceiling; - const ceilingStr = currentCeiling !== undefined ? String(currentCeiling) : ""; - const ceilingInput = await ctx.ui.input( - `Budget ceiling (USD)${ceilingStr ? ` (current: $${ceilingStr})` : " (default: no limit)"}:`, - ceilingStr || "", - ); - if (ceilingInput !== null && ceilingInput !== undefined) { - const val = ceilingInput.trim().replace(/^\$/, ""); - const parsed = tryParseNumber(val); - if (val && parsed !== null) { - prefs.budget_ceiling = parsed; - } else if (val) { - ctx.ui.notify(`Invalid budget ceiling "${val}" — must be a number. Keeping previous value.`, "warning"); - } else if (!val && ceilingStr) { - delete prefs.budget_ceiling; - } - } - - const currentEnforcement = (prefs.budget_enforcement as string) ?? ""; - const enforcementChoice = await ctx.ui.select( - `Budget enforcement${currentEnforcement ? ` (current: ${currentEnforcement})` : " (default: pause)"}:`, - ["warn", "pause", "halt", "(keep current)"], - ); - if (enforcementChoice && enforcementChoice !== "(keep current)") { - prefs.budget_enforcement = enforcementChoice; - } - - const currentContextPause = prefs.context_pause_threshold; - const contextPauseStr = currentContextPause !== undefined ? String(currentContextPause) : ""; - const contextPauseInput = await ctx.ui.input( - `Context pause threshold (0-100%, 0=disabled)${contextPauseStr ? ` (current: ${contextPauseStr}%)` : " (default: 0)"}:`, - contextPauseStr || "0", - ); - if (contextPauseInput !== null && contextPauseInput !== undefined) { - const val = contextPauseInput.trim().replace(/%$/, ""); - const parsed = tryParsePercentage(val); - if (val && parsed !== null) { - if (parsed === 0) { - delete prefs.context_pause_threshold; - } else { - prefs.context_pause_threshold = parsed; - } - } else if (val) { - ctx.ui.notify(`Invalid context pause threshold "${val}" — must be 0-100. Keeping previous value.`, "warning"); - } - } -} - -async function configureNotifications(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const notif: Record<string, boolean> = (prefs.notifications as Record<string, boolean>) ?? {}; - const notifFields = [ - { key: "enabled", label: "Notifications enabled (master toggle)", defaultVal: true }, - { key: "on_complete", label: "Notify on unit completion", defaultVal: true }, - { key: "on_error", label: "Notify on errors", defaultVal: true }, - { key: "on_budget", label: "Notify on budget thresholds", defaultVal: true }, - { key: "on_milestone", label: "Notify on milestone completion", defaultVal: true }, - { key: "on_attention", label: "Notify when manual attention needed", defaultVal: true }, - ] as const; - - for (const field of notifFields) { - const current = notif[field.key]; - const currentStr = current !== undefined && typeof current === "boolean" ? String(current) : ""; - const choice = await ctx.ui.select( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - ["true", "false", "(keep current)"], - ); - if (choice && choice !== "(keep current)") { - notif[field.key] = choice === "true"; - } - } - if (Object.keys(notif).length > 0) { - prefs.notifications = notif; - } -} - -export async function configureMode(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const currentMode = prefs.mode as string | undefined; - const modeChoice = await ctx.ui.select( - `Workflow mode${currentMode ? ` (current: ${currentMode})` : ""}:`, - [ - "solo — auto-push, squash, simple IDs (personal projects)", - "team — unique IDs, push branches, pre-merge checks (shared repos)", - "(none) — configure everything manually", - "(keep current)", - ], - ); - const modeStr = typeof modeChoice === "string" ? modeChoice : ""; - if (modeStr && modeStr !== "(keep current)") { - if (modeStr.startsWith("solo")) { - prefs.mode = "solo"; - ctx.ui.notify( - "Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=auto, merge_strategy=squash, isolation=worktree, unique_milestone_ids=false", - "info", - ); - } else if (modeStr.startsWith("team")) { - prefs.mode = "team"; - ctx.ui.notify( - "Mode: team — defaults: auto_push=false, push_branches=true, pre_merge_check=true, merge_strategy=squash, isolation=worktree, unique_milestone_ids=true", - "info", - ); - } else { - delete prefs.mode; - } - } -} - -async function configureAdvanced(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> { - const currentUnique = prefs.unique_milestone_ids; - const uniqueChoice = await ctx.ui.select( - `Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`, - ["true", "false", "(keep current)"], - ); - if (uniqueChoice && uniqueChoice !== "(keep current)") { - prefs.unique_milestone_ids = uniqueChoice === "true"; - } -} - -// ─── Main wizard with category menu ───────────────────────────────────────── - -export async function handlePrefsWizard( - ctx: ExtensionCommandContext, - scope: "global" | "project", -): Promise<void> { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); - const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {}; - - ctx.ui.notify(`SF preferences (${scope}) — pick a category to configure.`, "info"); - - while (true) { - const summaries = buildCategorySummaries(prefs); - const options = [ - `Workflow Mode ${summaries.mode}`, - `Models ${summaries.models}`, - `Timeouts ${summaries.timeouts}`, - `Git ${summaries.git}`, - `Skills ${summaries.skills}`, - `Budget ${summaries.budget}`, - `Notifications ${summaries.notifications}`, - `Advanced ${summaries.advanced}`, - `── Save & Exit ──`, - ]; - - const raw = await ctx.ui.select("SF Preferences", options); - const choice = typeof raw === "string" ? raw : ""; - if (!choice || choice.includes("Save & Exit")) break; - - if (choice.startsWith("Workflow Mode")) await configureMode(ctx, prefs); - else if (choice.startsWith("Models")) await configureModels(ctx, prefs); - else if (choice.startsWith("Timeouts")) await configureTimeouts(ctx, prefs); - else if (choice.startsWith("Git")) await configureGit(ctx, prefs); - else if (choice.startsWith("Skills")) await configureSkills(ctx, prefs); - else if (choice.startsWith("Budget")) await configureBudget(ctx, prefs); - else if (choice.startsWith("Notifications")) await configureNotifications(ctx, prefs); - else if (choice.startsWith("Advanced")) await configureAdvanced(ctx, prefs); - } - - // ─── Serialize to frontmatter ─────────────────────────────────────────── - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - - // Preserve existing body content (everything after closing ---) - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); - if (preserved) body = preserved; - } - - const content = `---\n${frontmatter}---${body}`; - - await saveFile(path, content); - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); -} - -/** Wrap a YAML value in double quotes if it contains special characters. */ -export function yamlSafeString(val: unknown): string { - if (typeof val !== "string") return String(val); - if (/[:#{\[\]'"`,|>&*!?@%]/.test(val) || val.trim() !== val || val === "") { - return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; - } - return val; -} - -export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>): string { - const lines: string[] = []; - - function serializeValue(key: string, value: unknown, indent: number): void { - const prefix = " ".repeat(indent); - if (value === null || value === undefined) return; - - if (Array.isArray(value)) { - if (value.length === 0) { - return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings - } - lines.push(`${prefix}${key}:`); - for (const item of value) { - if (typeof item === "object" && item !== null) { - const entries = Object.entries(item as Record<string, unknown>); - if (entries.length > 0) { - const [firstKey, firstVal] = entries[0]; - lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`); - for (let i = 1; i < entries.length; i++) { - const [k, v] = entries[i]; - if (Array.isArray(v)) { - lines.push(`${prefix} ${k}:`); - for (const arrItem of v) { - lines.push(`${prefix} - ${yamlSafeString(arrItem)}`); - } - } else { - lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`); - } - } - } - } else { - lines.push(`${prefix} - ${yamlSafeString(item)}`); - } - } - return; - } - - if (typeof value === "object") { - const entries = Object.entries(value as Record<string, unknown>); - if (entries.length === 0) { - return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings - } - lines.push(`${prefix}${key}:`); - for (const [k, v] of entries) { - serializeValue(k, v, indent + 1); - } - return; - } - - lines.push(`${prefix}${key}: ${yamlSafeString(value)}`); - } - - // Ordered keys for consistent output - const orderedKeys = [ - "version", "mode", "always_use_skills", "prefer_skills", "avoid_skills", - "skill_rules", "custom_instructions", "models", "skill_discovery", - "skill_staleness_days", "auto_supervisor", "uat_dispatch", "unique_milestone_ids", - "budget_ceiling", "budget_enforcement", "context_pause_threshold", - "notifications", "cmux", "remote_questions", "git", - "post_unit_hooks", "pre_dispatch_hooks", - "dynamic_routing", "uok", "token_profile", "phases", "parallel", - "auto_visualize", "auto_report", - "verification_commands", "verification_auto_fix", "verification_max_retries", - "search_provider", "context_selection", - ]; - - const seen = new Set<string>(); - for (const key of orderedKeys) { - if (key in prefs) { - serializeValue(key, prefs[key], 0); - seen.add(key); - } - } - // Any remaining keys not in the ordered list - for (const [key, value] of Object.entries(prefs)) { - if (!seen.has(key)) { - serializeValue(key, value, 0); - } - } - - return lines.join("\n") + "\n"; -} - -export async function ensurePreferencesFile( - path: string, - ctx: ExtensionCommandContext, - scope: "global" | "project", -): Promise<void> { - if (!existsSync(path)) { - const template = await loadFile(join(dirname(fileURLToPath(import.meta.url)), "templates", "PREFERENCES.md")); - if (!template) { - ctx.ui.notify("Could not load SF preferences template.", "error"); - return; - } - await saveFile(path, template); - ctx.ui.notify(`Created ${scope} SF skill preferences at ${path}`, "info"); - } else { - ctx.ui.notify(`Using existing ${scope} SF skill preferences at ${path}`, "info"); - } -} diff --git a/src/resources/extensions/gsd/commands-rate.ts b/src/resources/extensions/gsd/commands-rate.ts deleted file mode 100644 index daabe5e2f..000000000 --- a/src/resources/extensions/gsd/commands-rate.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * /gsd rate — Submit feedback on the last unit's model tier assignment. - * Feeds into the adaptive routing history so future dispatches improve. - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { loadLedgerFromDisk } from "./metrics.js"; -import { recordFeedback, initRoutingHistory } from "./routing-history.js"; -import type { ComplexityTier } from "./complexity-classifier.js"; - -const VALID_RATINGS = new Set(["over", "under", "ok"]); - -export async function handleRate( - args: string, - ctx: ExtensionCommandContext, - basePath: string, -): Promise<void> { - const rating = args.trim().toLowerCase(); - - if (!rating || !VALID_RATINGS.has(rating)) { - ctx.ui.notify( - "Usage: /gsd rate <over|ok|under>\n" + - " over — model was overpowered for that task (encourage cheaper)\n" + - " ok — model was appropriate\n" + - " under — model was too weak (encourage stronger)", - "info", - ); - return; - } - - const ledger = loadLedgerFromDisk(basePath); - if (!ledger || ledger.units.length === 0) { - ctx.ui.notify("No completed units found — nothing to rate.", "warning"); - return; - } - - const lastUnit = ledger.units[ledger.units.length - 1]; - const tier = lastUnit.tier as ComplexityTier | undefined; - - if (!tier) { - ctx.ui.notify( - "Last unit has no tier data (dynamic routing was not active). Rating skipped.", - "warning", - ); - return; - } - - initRoutingHistory(basePath); - recordFeedback(lastUnit.type, lastUnit.id, tier, rating as "over" | "under" | "ok"); - - ctx.ui.notify( - `Recorded "${rating}" for ${lastUnit.type}/${lastUnit.id} at tier ${tier}.`, - "info", - ); -} diff --git a/src/resources/extensions/gsd/commands-session-report.ts b/src/resources/extensions/gsd/commands-session-report.ts deleted file mode 100644 index 40e312d7d..000000000 --- a/src/resources/extensions/gsd/commands-session-report.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * SF Command — /gsd session-report - * - * Summarizes the current session: tasks completed, cost, tokens, - * duration, model usage breakdown. - */ - -import type { ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { getLedger, getProjectTotals, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk } from "./metrics.js"; -import type { UnitMetrics } from "./metrics.js"; -import { gsdRoot } from "./paths.js"; -import { formatDuration } from "../shared/format-utils.js"; - -function formatSessionReport(units: UnitMetrics[]): string { - const totals = getProjectTotals(units); - const byModel = aggregateByModel(units); - - const lines: string[] = []; - lines.push("╭─ Session Report ──────────────────────────────────────╮"); - - if (totals.duration > 0) { - lines.push(`│ Duration: ${formatDuration(totals.duration).padEnd(40)}│`); - } - lines.push(`│ Units: ${String(units.length).padEnd(40)}│`); - lines.push(`│ Cost: ${formatCost(totals.cost).padEnd(40)}│`); - lines.push(`│ Tokens: ${`${formatTokenCount(totals.tokens.input)} in / ${formatTokenCount(totals.tokens.output)} out`.padEnd(40)}│`); - lines.push("│ │"); - - // Work completed - if (units.length > 0) { - lines.push("│ Work Completed: │"); - for (const unit of units) { - const finished = unit.finishedAt > 0; - const status = finished ? "✓" : "•"; - const label = ` ${status} ${unit.id ?? "unknown"}`; - lines.push(`│ ${label.padEnd(53)}│`); - } - lines.push("│ │"); - } - - // Model usage - if (byModel.length > 0) { - lines.push("│ Model Usage: │"); - for (const m of byModel) { - const label = ` ${m.model}: ${m.units} units (${formatCost(m.cost)})`; - lines.push(`│ ${label.padEnd(53)}│`); - } - } - - lines.push("╰───────────────────────────────────────────────────────╯"); - return lines.join("\n"); -} - -export async function handleSessionReport( - args: string, - ctx: ExtensionCommandContext, -): Promise<void> { - const basePath = process.cwd(); - - // Get units from in-memory ledger or disk - const ledger = getLedger(); - let units: UnitMetrics[]; - - if (ledger && ledger.units.length > 0) { - units = ledger.units; - } else { - const diskLedger = loadLedgerFromDisk(basePath); - if (!diskLedger || diskLedger.units.length === 0) { - ctx.ui.notify("No session data — no units have been executed yet.", "info"); - return; - } - units = diskLedger.units; - } - - // JSON output - if (args.includes("--json")) { - const totals = getProjectTotals(units); - const byModel = aggregateByModel(units); - ctx.ui.notify(JSON.stringify({ units: units.length, totals, byModel }, null, 2), "info"); - return; - } - - // Save to file - if (args.includes("--save")) { - const report = formatSessionReport(units); - const reportsDir = join(gsdRoot(basePath), "reports"); - mkdirSync(reportsDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const outPath = join(reportsDir, `session-${timestamp}.md`); - writeFileSync(outPath, `\`\`\`\n${report}\n\`\`\`\n`, "utf-8"); - ctx.ui.notify(`Report saved: ${outPath}`, "success"); - return; - } - - // Display - ctx.ui.notify(formatSessionReport(units), "info"); -} diff --git a/src/resources/extensions/gsd/commands-ship.ts b/src/resources/extensions/gsd/commands-ship.ts deleted file mode 100644 index 3d8365d01..000000000 --- a/src/resources/extensions/gsd/commands-ship.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * SF Command — /gsd ship - * - * Creates a PR from milestone artifacts: generates title + body from - * roadmap, slice summaries, and metrics, then opens via `gh pr create`. - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; - -import { deriveState } from "./state.js"; -import { resolveMilestoneFile, resolveSlicePath, resolveSliceFile } from "./paths.js"; -import { getLedger, getProjectTotals, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk } from "./metrics.js"; -import { nativeGetCurrentBranch, nativeDetectMainBranch } from "./native-git-bridge.js"; -import { formatDuration } from "../shared/format-utils.js"; - -function git(basePath: string, args: readonly string[]): string { - return execFileSync("git", args, { cwd: basePath, encoding: "utf-8" }).trim(); -} - -function isValidRefName(name: string): boolean { - try { - execFileSync("git", ["check-ref-format", "--branch", name], { stdio: "pipe" }); - return true; - } catch { - return false; - } -} - -interface PRContent { - title: string; - body: string; -} - -function listSliceIds(basePath: string, milestoneId: string): string[] { - // Slices live at <milestoneDir>/slices/<sliceId>/ with canonical S\d+ IDs. - // Use resolveSlicePath with a probe to find the real slices directory root. - const probe = resolveSlicePath(basePath, milestoneId, "S01"); - let slicesDir: string | null = null; - if (probe) { - // probe looks like <milestoneDir>/slices/S01 — parent is slices dir. - slicesDir = probe.replace(/[\\/][^\\/]+$/, ""); - } else { - // Fall back to scanning the milestones roadmap file's sibling slices dir. - const roadmap = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - if (roadmap) { - slicesDir = roadmap.replace(/[\\/][^\\/]+$/, "") + "/slices"; - } - } - if (!slicesDir || !existsSync(slicesDir)) return []; - - try { - return readdirSync(slicesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && /^S\d+$/.test(e.name)) - .map((e) => e.name) - .sort(); - } catch { - return []; - } -} - -function collectSliceSummaries(basePath: string, milestoneId: string): string[] { - const summaries: string[] = []; - for (const sliceId of listSliceIds(basePath, milestoneId)) { - const summaryPath = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY"); - if (!summaryPath || !existsSync(summaryPath)) continue; - try { - const content = readFileSync(summaryPath, "utf-8").trim(); - if (content) summaries.push(`### ${sliceId}\n${content}`); - } catch { - // non-fatal - } - } - return summaries; -} - -function generatePRContent(basePath: string, milestoneId: string, milestoneTitle: string): PRContent { - const title = `feat: ${milestoneTitle || milestoneId}`; - - const sections: string[] = []; - - // TL;DR - sections.push("## TL;DR\n"); - sections.push(`**What:** Ship milestone ${milestoneId} — ${milestoneTitle || "(untitled)"}`); - sections.push(`**Why:** Milestone work complete, ready for review.`); - sections.push(`**How:** See slice summaries below.\n`); - - // What — slice summaries - const summaries = collectSliceSummaries(basePath, milestoneId); - if (summaries.length > 0) { - sections.push("## What\n"); - sections.push(summaries.join("\n\n")); - sections.push(""); - } - - // Roadmap status - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - if (roadmapPath && existsSync(roadmapPath)) { - try { - const roadmap = readFileSync(roadmapPath, "utf-8"); - const checkboxLines = roadmap.split("\n").filter((l) => /^\s*-\s*\[[ x]\]/.test(l)); - if (checkboxLines.length > 0) { - sections.push("## Roadmap\n"); - sections.push(checkboxLines.join("\n")); - sections.push(""); - } - } catch { - // non-fatal - } - } - - // Metrics - const ledger = getLedger(); - const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? []; - if (units.length > 0) { - const totals = getProjectTotals(units); - const byModel = aggregateByModel(units); - sections.push("## Metrics\n"); - sections.push(`- **Units executed:** ${units.length}`); - sections.push(`- **Total cost:** ${formatCost(totals.cost)}`); - sections.push(`- **Tokens:** ${formatTokenCount(totals.tokens.input)} input / ${formatTokenCount(totals.tokens.output)} output`); - if (totals.duration > 0) { - sections.push(`- **Duration:** ${formatDuration(totals.duration)}`); - } - if (byModel.length > 0) { - sections.push(`- **Models:** ${byModel.map((m) => `${m.model} (${m.units} units)`).join(", ")}`); - } - sections.push(""); - } - - // Change type checklist - sections.push("## Change type\n"); - sections.push("- [x] `feat` — New feature or capability"); - sections.push("- [ ] `fix` — Bug fix"); - sections.push("- [ ] `refactor` — Code restructuring"); - sections.push("- [ ] `test` — Adding or updating tests"); - sections.push("- [ ] `docs` — Documentation only"); - sections.push("- [ ] `chore` — Build, CI, or tooling changes\n"); - - // AI disclosure - sections.push("---\n"); - sections.push("*This PR was prepared with AI assistance (SF auto-mode).*"); - - return { title, body: sections.join("\n") }; -} - -export async function handleShip( - args: string, - ctx: ExtensionCommandContext, - _pi: ExtensionAPI, -): Promise<void> { - const basePath = process.cwd(); - const dryRun = args.includes("--dry-run"); - const draft = args.includes("--draft"); - const force = args.includes("--force"); - const baseMatch = args.match(/--base\s+(\S+)/); - const base = baseMatch?.[1] ?? nativeDetectMainBranch(basePath); - - if (!isValidRefName(base)) { - ctx.ui.notify(`Invalid base branch name: ${base}`, "error"); - return; - } - - // 1. Validate milestone state - const state = await deriveState(basePath); - if (!state.activeMilestone) { - ctx.ui.notify("No active milestone to ship. Complete milestone work first.", "warning"); - return; - } - - const milestoneId = state.activeMilestone.id; - const milestoneTitle = state.activeMilestone.title ?? ""; - - // 2. Check for incomplete work (use SF phase as proxy — no phase field on ActiveRef) - if (state.phase !== "complete" && !force) { - ctx.ui.notify( - `Milestone ${milestoneId} may not be complete (phase: ${state.phase}). Use --force to ship anyway.`, - "warning", - ); - return; - } - - // 3. Generate PR content - const { title, body } = generatePRContent(basePath, milestoneId, milestoneTitle); - - // 4. Dry-run — just show the PR content - if (dryRun) { - ctx.ui.notify(`--- PR Preview ---\n\nTitle: ${title}\n\n${body}`, "info"); - return; - } - - // 5. Check git state - const currentBranch = nativeGetCurrentBranch(basePath); - if (!isValidRefName(currentBranch)) { - ctx.ui.notify(`Current branch name is invalid for git: ${currentBranch}`, "error"); - return; - } - if (currentBranch === base) { - ctx.ui.notify(`You're on ${base} — create a feature branch first.`, "warning"); - return; - } - - // 6. Push and create PR (all argv-safe, no shell interpolation) - try { - git(basePath, ["push", "-u", "origin", currentBranch]); - - const ghArgs = ["pr", "create", "--base", base, "--title", title, "--body", body]; - if (draft) ghArgs.push("--draft"); - - const prUrl = execFileSync("gh", ghArgs, { cwd: basePath, encoding: "utf-8" }).trim(); - - ctx.ui.notify(`PR created: ${prUrl}`, "success"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to create PR: ${msg}`, "error"); - } -} diff --git a/src/resources/extensions/gsd/commands-workflow-templates.ts b/src/resources/extensions/gsd/commands-workflow-templates.ts deleted file mode 100644 index 92ae82bde..000000000 --- a/src/resources/extensions/gsd/commands-workflow-templates.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * SF Workflow Template Commands — /gsd start, /gsd templates - * - * Handles the `/gsd start [template] [description]` and `/gsd templates` commands. - * Resolves templates by name or auto-detection, then dispatches the workflow prompt. - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { - resolveByName, - autoDetect, - listTemplates, - getTemplateInfo, - loadWorkflowTemplate, - loadRegistry, - type TemplateMatch, -} from "./workflow-templates.js"; -import { loadPrompt } from "./prompt-loader.js"; -import { gsdRoot } from "./paths.js"; -import { createGitService, runGit } from "./git-service.js"; -import { isAutoActive, isAutoPaused } from "./auto.js"; -import { getErrorMessage } from "./error-utils.js"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** - * Generate a URL-friendly slug from text. - */ -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 40) - .replace(/-$/, ""); -} - -/** - * Get the next workflow task number by scanning existing directories. - */ -function getNextWorkflowNum(workflowDir: string): number { - if (!existsSync(workflowDir)) return 1; - try { - const entries = readdirSync(workflowDir, { withFileTypes: true }); - let max = 0; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const match = entry.name.match(/^(\d{6})-(\d+)-/); - if (match) { - const num = parseInt(match[2], 10); - if (num > max) max = num; - } - } - return max + 1; - } catch { - return 1; - } -} - -/** - * Format the date as YYMMDD for directory naming. - */ -function datePrefix(): string { - const d = new Date(); - const yy = String(d.getFullYear()).slice(2); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - const dd = String(d.getDate()).padStart(2, "0"); - return `${yy}${mm}${dd}`; -} - -// ─── State Types ───────────────────────────────────────────────────────────── - -interface WorkflowPhaseState { - name: string; - index: number; - status: "pending" | "active" | "completed"; -} - -interface WorkflowState { - template: string; - templateName: string; - description: string; - branch: string; - phases: WorkflowPhaseState[]; - currentPhase: number; - startedAt: string; - updatedAt: string; - completedAt?: string; - artifactDir: string; -} - -/** - * Write a STATE.json file to track workflow execution state. - */ -function writeWorkflowState( - artifactDir: string, - templateId: string, - templateName: string, - phases: string[], - description: string, - branch: string, -): void { - const statePath = join(artifactDir, "STATE.json"); - const state: WorkflowState = { - template: templateId, - templateName, - description, - branch, - phases: phases.map((p, i) => ({ - name: p, - index: i, - status: i === 0 ? "active" as const : "pending" as const, - })), - currentPhase: 0, - startedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - artifactDir, - }; - writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n"); -} - -/** - * Scan all workflow artifact directories for in-progress STATE.json files. - * Returns workflows that were started but not completed. - */ -function findInProgressWorkflows(basePath: string): WorkflowState[] { - const workflowsRoot = join(gsdRoot(basePath), "workflows"); - if (!existsSync(workflowsRoot)) return []; - - const results: WorkflowState[] = []; - try { - // Scan each category dir (bugfixes/, features/, spikes/, etc.) - for (const category of readdirSync(workflowsRoot, { withFileTypes: true })) { - if (!category.isDirectory()) continue; - const categoryDir = join(workflowsRoot, category.name); - - for (const workflow of readdirSync(categoryDir, { withFileTypes: true })) { - if (!workflow.isDirectory()) continue; - const statePath = join(categoryDir, workflow.name, "STATE.json"); - if (!existsSync(statePath)) continue; - - try { - const raw = readFileSync(statePath, "utf-8"); - const state = JSON.parse(raw) as WorkflowState; - if (!state.completedAt) { - results.push(state); - } - } catch { /* corrupted state file — skip */ } - } - } - } catch { /* workflows dir unreadable — skip */ } - - // Sort by most recently updated - results.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - return results; -} - -// ─── /gsd start ────────────────────────────────────────────────────────────── - -export async function handleStart( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - const trimmed = args.trim(); - - // /gsd start --list → same as /gsd templates - if (trimmed === "--list" || trimmed === "list") { - ctx.ui.notify(listTemplates(), "info"); - return; - } - - // ─── Auto-mode conflict guard ────────────────────────────────────────── - // Workflow templates dispatch their own messages and switch git branches, - // which would conflict with an active auto-mode dispatch loop. - if (isAutoActive()) { - ctx.ui.notify( - "Cannot start a workflow template while auto-mode is running.\n" + - "Run /gsd pause first, then /gsd start.", - "warning", - ); - return; - } - - if (isAutoPaused()) { - ctx.ui.notify( - "Auto-mode is paused. Starting a workflow template will run independently.\n" + - "The paused auto-mode session can be resumed later with /gsd auto.", - "info", - ); - } - - // ─── Resume detection ─────────────────────────────────────────────────── - // /gsd start --resume or /gsd start resume → resume in-progress workflow - if (trimmed === "--resume" || trimmed === "resume") { - const basePath = process.cwd(); - const inProgress = findInProgressWorkflows(basePath); - if (inProgress.length === 0) { - ctx.ui.notify("No in-progress workflows found.", "info"); - return; - } - - // Resume the most recent one - const wf = inProgress[0]; - const activePhase = wf.phases.find(p => p.status === "active"); - const completedCount = wf.phases.filter(p => p.status === "completed").length; - - ctx.ui.notify( - `Resuming: ${wf.templateName}\n` + - `Description: ${wf.description}\n` + - `Progress: ${completedCount}/${wf.phases.length} phases completed\n` + - `Current phase: ${activePhase?.name ?? "unknown"}\n` + - `Branch: ${wf.branch}\n` + - `Artifacts: ${wf.artifactDir}`, - "info", - ); - - const workflowContent = loadWorkflowTemplate(wf.template); - if (!workflowContent) { - ctx.ui.notify(`Template "${wf.template}" workflow file not found.`, "warning"); - return; - } - - const prompt = loadPrompt("workflow-start", { - templateId: wf.template, - templateName: wf.templateName, - templateDescription: `RESUMING — pick up from phase "${activePhase?.name ?? "unknown"}" (${completedCount}/${wf.phases.length} phases done)`, - phases: wf.phases.map(p => `${p.name}${p.status === "completed" ? " ✓" : p.status === "active" ? " ←" : ""}`).join(" → "), - complexity: "resume", - artifactDir: wf.artifactDir, - branch: wf.branch, - description: wf.description, - issueRef: "(none)", - date: new Date().toISOString().split("T")[0], - workflowContent, - }); - - pi.sendMessage( - { customType: "gsd-workflow-template", content: prompt, display: false }, - { triggerTurn: true }, - ); - return; - } - - // Show in-progress workflows when /gsd start is called with no args - if (!trimmed) { - const basePath = process.cwd(); - const inProgress = findInProgressWorkflows(basePath); - if (inProgress.length > 0) { - const wf = inProgress[0]; - const activePhase = wf.phases.find(p => p.status === "active"); - const completedCount = wf.phases.filter(p => p.status === "completed").length; - ctx.ui.notify( - `In-progress workflow found:\n` + - ` ${wf.templateName}: "${wf.description}"\n` + - ` Phase ${completedCount + 1}/${wf.phases.length}: ${activePhase?.name ?? "unknown"}\n\n` + - `Run /gsd start resume to continue it.\n`, - "info", - ); - } - } - - // /gsd start --dry-run <template> → preview without executing - const dryRun = trimmed.includes("--dry-run"); - const cleanedArgs = trimmed.replace(/--dry-run\s*/, "").trim(); - - // Parse: first word might be a template name, rest is description - const parts = cleanedArgs.split(/\s+/); - const firstWord = parts[0] ?? ""; - - // Check for --issue flag (bugfix shortcut) - const issueMatch = cleanedArgs.match(/--issue\s+(\S+)/); - const issueRef = issueMatch ? issueMatch[1] : null; - - // Try resolving first word as a template name - let match: TemplateMatch | null = null; - let description = ""; - - if (firstWord) { - match = resolveByName(firstWord); - if (match) { - // First word was a template name; rest is description - description = parts.slice(1).join(" ").replace(/--issue\s+\S+/, "").trim(); - } - } - - // If no explicit template, try auto-detection from the full input - if (!match && cleanedArgs) { - const detected = autoDetect(cleanedArgs); - if (detected.length === 1 || (detected.length > 0 && detected[0].confidence === "high")) { - match = detected[0]; - description = cleanedArgs; - ctx.ui.notify( - `Auto-detected template: ${match.template.name} (matched: "${match.matchedTrigger}")`, - "info", - ); - } else if (detected.length > 1) { - const choices = detected.slice(0, 4).map( - (m) => ` /gsd start ${m.id} ${cleanedArgs}` - ); - ctx.ui.notify( - `Multiple templates could match. Pick one:\n\n${choices.join("\n")}\n\nOr specify explicitly: /gsd start <template> <description>`, - "info", - ); - return; - } - } - - // No template resolved at all - if (!match) { - if (!trimmed) { - ctx.ui.notify( - "Usage: /gsd start <template> [description]\n\n" + - "Templates:\n" + - " bugfix Triage → fix → verify → ship\n" + - " small-feature Scope → plan → implement → verify\n" + - " spike Scope → research → synthesize\n" + - " hotfix Fix → ship (minimal ceremony)\n" + - " refactor Inventory → plan → migrate → verify\n" + - " security-audit Scan → triage → remediate → re-scan\n" + - " dep-upgrade Assess → upgrade → fix → verify\n" + - " full-project Complete SF with full ceremony\n\n" + - "Examples:\n" + - " /gsd start bugfix fix login button not responding\n" + - " /gsd start spike evaluate auth libraries\n" + - " /gsd start hotfix critical: API returns 500\n\n" + - "Flags:\n" + - " --dry-run Preview what would happen without executing\n" + - " --issue <ref> Link to a GitHub issue\n\n" + - "Run /gsd templates for detailed template info.", - "info", - ); - } else { - ctx.ui.notify( - `No template matched "${firstWord}". Run /gsd start to see available templates.`, - "warning", - ); - } - return; - } - - // ─── Resolved template ─────────────────────────────────────────────────── - - const templateId = match.id; - const template = match.template; - const basePath = process.cwd(); - const date = new Date().toISOString().split("T")[0]; - - // Load the workflow template content - const workflowContent = loadWorkflowTemplate(templateId); - if (!workflowContent) { - ctx.ui.notify( - `Template "${templateId}" is registered but its workflow file (${template.file}) hasn't been created yet.`, - "warning", - ); - return; - } - - // ─── Dry-run mode: preview without executing ──────────────────────────── - - if (dryRun) { - const slug = slugify(description || templateId); - const lines = [ - `DRY RUN — ${template.name} (${templateId})\n`, - `Description: ${description || "(none)"}`, - `Complexity: ${template.estimated_complexity}`, - `Phases: ${template.phases.join(" → ")}`, - "", - ]; - if (template.artifact_dir) { - const prefix = datePrefix(); - const num = getNextWorkflowNum(join(basePath, template.artifact_dir)); - lines.push(`Artifact dir: ${template.artifact_dir}${prefix}-${num}-${slug}`); - } else { - lines.push("Artifact dir: (none — hotfix mode)"); - } - lines.push(`Branch: gsd/${templateId}/${slug}`); - if (issueRef) lines.push(`Issue: ${issueRef}`); - lines.push("", "No changes made. Remove --dry-run to execute."); - ctx.ui.notify(lines.join("\n"), "info"); - return; - } - - // ─── Route full-project to standard SF workflow ──────────────────────── - - if (templateId === "full-project") { - const root = gsdRoot(basePath); - if (!existsSync(root)) { - ctx.ui.notify( - "Routing to /gsd init for full project setup...", - "info", - ); - // Trigger /gsd init by dispatching to the handler - pi.sendMessage( - { - customType: "gsd-workflow-template", - content: "The user wants to start a full SF project. Run `/gsd init` to bootstrap the project, then `/gsd auto` to begin execution.", - display: false, - }, - { triggerTurn: true }, - ); - } else { - ctx.ui.notify( - "Project already initialized. Use `/gsd auto` to continue or `/gsd discuss` to start a new milestone.", - "info", - ); - } - return; - } - - // ─── Create artifact directory ────────────────────────────────────────── - - let artifactDir = ""; - if (template.artifact_dir) { - const slug = slugify(description || templateId); - const prefix = datePrefix(); - const num = getNextWorkflowNum(join(basePath, template.artifact_dir)); - artifactDir = `${template.artifact_dir}${prefix}-${num}-${slug}`; - mkdirSync(join(basePath, artifactDir), { recursive: true }); - } - - // ─── Create git branch (unless isolation: none) ───────────────────────── - - const git = createGitService(basePath); - const skipBranch = git.prefs.isolation === "none"; - const slug = slugify(description || templateId); - const branchName = `gsd/${templateId}/${slug}`; - let branchCreated = false; - - if (!skipBranch) { - try { - const current = git.getCurrentBranch(); - if (current !== branchName) { - try { - git.autoCommit("workflow-template", templateId, []); - } catch { /* nothing to commit */ } - runGit(basePath, ["checkout", "-b", branchName]); - branchCreated = true; - } - } catch (err) { - const message = getErrorMessage(err); - ctx.ui.notify( - `Could not create branch ${branchName}: ${message}. Working on current branch.`, - "warning", - ); - } - } - - const actualBranch = branchCreated ? branchName : git.getCurrentBranch(); - - // ─── Write workflow state for resume support ──────────────────────────── - - if (artifactDir) { - writeWorkflowState( - join(basePath, artifactDir), - templateId, - template.name, - template.phases, - description, - actualBranch, - ); - } - - // ─── Notify and dispatch ──────────────────────────────────────────────── - - const infoLines = [ - `Starting workflow: ${template.name}`, - `Phases: ${template.phases.join(" → ")}`, - ]; - if (artifactDir) infoLines.push(`Artifacts: ${artifactDir}`); - infoLines.push(`Branch: ${actualBranch}`); - ctx.ui.notify(infoLines.join("\n"), "info"); - - const prompt = loadPrompt("workflow-start", { - templateId, - templateName: template.name, - templateDescription: template.description, - phases: template.phases.join(" → "), - complexity: template.estimated_complexity, - artifactDir: artifactDir || "(none)", - branch: actualBranch, - description: description || "(none provided)", - issueRef: issueRef || "(none)", - date, - workflowContent, - }); - - pi.sendMessage( - { - customType: "gsd-workflow-template", - content: prompt, - display: false, - }, - { triggerTurn: true }, - ); -} - -// ─── /gsd templates ────────────────────────────────────────────────────────── - -export async function handleTemplates( - args: string, - ctx: ExtensionCommandContext, -): Promise<void> { - const trimmed = args.trim(); - - // /gsd templates info <name> - if (trimmed.startsWith("info ")) { - const name = trimmed.replace(/^info\s+/, "").trim(); - const info = getTemplateInfo(name); - if (info) { - ctx.ui.notify(info, "info"); - } else { - ctx.ui.notify( - `Unknown template "${name}". Run /gsd templates to see available templates.`, - "warning", - ); - } - return; - } - - // /gsd templates — list all - ctx.ui.notify(listTemplates(), "info"); -} - -/** - * Return template IDs for autocomplete in /gsd templates info <name>. - */ -export function getTemplateCompletions(prefix: string): Array<{ value: string; label: string; description: string }> { - try { - const registry = loadRegistry(); - return Object.entries(registry.templates) - .filter(([id]) => id.startsWith(prefix)) - .map(([id, entry]) => ({ - value: `info ${id}`, - label: id, - description: entry.description, - })); - } catch { - return []; - } -} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts deleted file mode 100644 index 9d98fc068..000000000 --- a/src/resources/extensions/gsd/commands.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { registerGSDCommand } from "./commands/index.js"; - -export async function handleGSDCommand( - ...args: Parameters<typeof import("./commands/dispatcher.js").handleGSDCommand> -) { - const { handleGSDCommand: dispatch } = await import("./commands/dispatcher.js"); - return dispatch(...args); -} - -export async function fireStatusViaCommand( - ...args: Parameters<typeof import("./commands/handlers/core.js").fireStatusViaCommand> -) { - const { fireStatusViaCommand: fireStatus } = await import( - "./commands/handlers/core.js" - ); - return fireStatus(...args); -} diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts deleted file mode 100644 index ba746349f..000000000 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -import { loadRegistry } from "../workflow-templates.js"; -import { resolveProjectRoot } from "../worktree.js"; - -const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); - -export interface GsdCommandDefinition { - cmd: string; - desc: string; -} - -type CompletionMap = Record<string, readonly GsdCommandDefinition[]>; - -export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|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|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"; - -export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ - { cmd: "help", desc: "Categorized command reference with descriptions" }, - { cmd: "next", desc: "Explicit step mode (same as /gsd)" }, - { cmd: "auto", desc: "Autonomous mode — research, plan, execute, commit, repeat" }, - { cmd: "stop", desc: "Stop auto mode gracefully" }, - { cmd: "pause", desc: "Pause auto-mode (preserves state, /gsd auto to resume)" }, - { cmd: "status", desc: "Progress dashboard" }, - { cmd: "widget", desc: "Cycle widget: full → small → min → off" }, - { cmd: "visualize", desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)" }, - { cmd: "queue", desc: "Queue and reorder future milestones" }, - { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, - { cmd: "discuss", desc: "Discuss architecture and decisions" }, - { cmd: "capture", desc: "Fire-and-forget thought capture" }, - { cmd: "changelog", desc: "Show categorized release notes" }, - { cmd: "triage", desc: "Manually trigger triage of pending captures" }, - { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, - { cmd: "history", desc: "View execution history" }, - { cmd: "undo", desc: "Revert last completed unit" }, - { cmd: "undo-task", desc: "Reset a specific task's completion state (DB + markdown)" }, - { cmd: "reset-slice", desc: "Reset a slice and all its tasks (DB + markdown)" }, - { cmd: "rate", desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing" }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, - { cmd: "export", desc: "Export milestone/slice results" }, - { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, - { cmd: "model", desc: "Switch the active session model or open a picker" }, - { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, - { cmd: "prefs", desc: "Manage preferences (model selection, timeouts, etc.)" }, - { cmd: "config", desc: "Set API keys for external tools" }, - { cmd: "keys", desc: "API key manager — list, add, remove, test, rotate, doctor" }, - { cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" }, - { cmd: "run-hook", desc: "Manually trigger a specific hook" }, - { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, - { cmd: "notifications", desc: "View, filter, and clear persistent notification history" }, - { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, - { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, - { cmd: "forensics", desc: "Examine execution logs" }, - { cmd: "init", desc: "Project init wizard — detect, configure, bootstrap .gsd/" }, - { cmd: "setup", desc: "Global setup status and configuration" }, - { cmd: "migrate", desc: "Migrate a v1 .planning directory to .gsd format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, - { cmd: "steer", desc: "Hard-steer plan documents during execution" }, - { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, - { cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" }, - { cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" }, - { cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge, watch)" }, - { cmd: "cmux", desc: "Manage cmux integration (status, sidebar, notifications, splits)" }, - { cmd: "park", desc: "Park a milestone — skip without deleting" }, - { cmd: "unpark", desc: "Reactivate a parked milestone" }, - { cmd: "update", desc: "Update SF to the latest version" }, - { cmd: "start", desc: "Start a workflow template (bugfix, spike, feature, etc.)" }, - { cmd: "templates", desc: "List available workflow templates" }, - { cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" }, - { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, - { cmd: "mcp", desc: "MCP server status, connectivity, and local config bootstrap (status, check, init)" }, - { cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" }, - { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, - { cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache (.gsd/CODEBASE.md)" }, - { cmd: "ship", desc: "Create PR from milestone artifacts and open for review" }, - { cmd: "do", desc: "Route freeform text to the right SF command" }, - { cmd: "session-report", desc: "Session cost, tokens, and work summary" }, - { cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" }, - { cmd: "pr-branch", desc: "Create clean PR branch filtering .gsd/ commits" }, - { cmd: "add-tests", desc: "Generate tests for completed slices" }, -]; - -const NESTED_COMPLETIONS: CompletionMap = { - auto: [ - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], - next: [ - { cmd: "--verbose", desc: "Show detailed step output" }, - { cmd: "--dry-run", desc: "Preview next step without executing" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], - widget: [ - { cmd: "full", desc: "Full widget display" }, - { cmd: "small", desc: "Compact widget display" }, - { cmd: "min", desc: "Minimal widget display" }, - { cmd: "off", desc: "Hide widget" }, - ], - mode: [ - { cmd: "global", desc: "Edit global workflow mode" }, - { cmd: "project", desc: "Edit project-specific workflow mode" }, - ], - parallel: [ - { cmd: "start", desc: "Start parallel milestone orchestration" }, - { cmd: "status", desc: "Show parallel worker statuses" }, - { cmd: "stop", desc: "Stop all parallel workers" }, - { cmd: "pause", desc: "Pause a specific worker" }, - { cmd: "resume", desc: "Resume a paused worker" }, - { cmd: "merge", desc: "Merge completed milestone branches" }, - { cmd: "watch", desc: "Live TUI dashboard monitoring all workers" }, - ], - setup: [ - { cmd: "llm", desc: "Configure LLM provider settings" }, - { cmd: "search", desc: "Configure web search provider" }, - { cmd: "remote", desc: "Configure remote integrations" }, - { cmd: "keys", desc: "Manage API keys" }, - { cmd: "prefs", desc: "Configure global preferences" }, - ], - notifications: [ - { cmd: "clear", desc: "Clear all notifications" }, - { cmd: "tail", desc: "Show last N notifications (default: 20)" }, - { cmd: "filter", desc: "Filter by severity (error|warning|info|success)" }, - ], - logs: [ - { cmd: "debug", desc: "List or view debug log files" }, - { cmd: "tail", desc: "Show last N activity log summaries" }, - { cmd: "clear", desc: "Remove old activity and debug logs" }, - ], - keys: [ - { cmd: "list", desc: "Show key status dashboard" }, - { cmd: "add", desc: "Add a key for a provider" }, - { cmd: "remove", desc: "Remove a key" }, - { cmd: "test", desc: "Validate key(s) with API call" }, - { cmd: "rotate", desc: "Replace an existing key" }, - { cmd: "doctor", desc: "Health check all keys" }, - ], - prefs: [ - { cmd: "global", desc: "Edit global preferences file" }, - { cmd: "project", desc: "Edit project preferences file" }, - { cmd: "status", desc: "Show effective preferences" }, - { cmd: "wizard", desc: "Interactive preferences wizard" }, - { cmd: "setup", desc: "First-time preferences setup" }, - { cmd: "import-claude", desc: "Import settings from Claude Code" }, - ], - remote: [ - { cmd: "slack", desc: "Configure Slack integration" }, - { cmd: "discord", desc: "Configure Discord integration" }, - { cmd: "status", desc: "Show remote connection status" }, - { cmd: "disconnect", desc: "Disconnect remote integrations" }, - ], - history: [ - { cmd: "--cost", desc: "Show cost breakdown per entry" }, - { cmd: "--phase", desc: "Filter by phase type" }, - { cmd: "--model", desc: "Filter by model used" }, - { cmd: "10", desc: "Show last 10 entries" }, - { cmd: "20", desc: "Show last 20 entries" }, - { cmd: "50", desc: "Show last 50 entries" }, - ], - export: [ - { cmd: "--json", desc: "Export as JSON" }, - { cmd: "--markdown", desc: "Export as Markdown" }, - { cmd: "--html", desc: "Export as HTML" }, - { cmd: "--html --all", desc: "Export all milestones as HTML" }, - ], - cleanup: [ - { cmd: "branches", desc: "Remove merged milestone and legacy branches" }, - { cmd: "snapshots", desc: "Remove old execution snapshots" }, - { cmd: "worktrees", desc: "Remove merged/safe-to-delete worktrees" }, - { cmd: "projects", desc: "Audit orphaned ~/.gsd/projects/ state directories" }, - { cmd: "projects --fix", desc: "Delete orphaned project state directories (cannot be undone)" }, - ], - knowledge: [ - { cmd: "rule", desc: "Add a project rule (always/never do X)" }, - { cmd: "pattern", desc: "Add a code pattern to follow" }, - { cmd: "lesson", desc: "Record a lesson learned" }, - ], - start: [ - { cmd: "bugfix", desc: "Triage, fix, test, and ship a bug fix" }, - { cmd: "small-feature", desc: "Lightweight feature with optional discussion" }, - { cmd: "spike", desc: "Research, prototype, and document findings" }, - { cmd: "hotfix", desc: "Minimal: fix it, test it, ship it" }, - { cmd: "refactor", desc: "Inventory, plan waves, migrate, verify" }, - { cmd: "security-audit", desc: "Scan, triage, remediate, re-scan" }, - { cmd: "dep-upgrade", desc: "Assess, upgrade, fix breaks, verify" }, - { cmd: "full-project", desc: "Complete SF workflow with full ceremony" }, - { cmd: "resume", desc: "Resume an in-progress workflow" }, - { cmd: "--list", desc: "List all available templates" }, - { cmd: "--dry-run", desc: "Preview workflow without executing" }, - ], - templates: [ - { cmd: "info", desc: "Show detailed template info" }, - ], - extensions: [ - { cmd: "list", desc: "List all extensions and their status" }, - { cmd: "enable", desc: "Enable a disabled extension" }, - { cmd: "disable", desc: "Disable an extension" }, - { cmd: "info", desc: "Show extension details" }, - ], - fast: [ - { cmd: "on", desc: "Priority tier (2x cost, faster)" }, - { cmd: "off", desc: "Disable service tier" }, - { cmd: "flex", desc: "Flex tier (0.5x cost, slower)" }, - { cmd: "status", desc: "Show current service tier setting" }, - ], - mcp: [ - { cmd: "status", desc: "Show all MCP server statuses (default)" }, - { cmd: "check", desc: "Detailed status for a specific server" }, - { cmd: "init", desc: "Write .mcp.json for the local SF workflow MCP server" }, - ], - doctor: [ - { cmd: "fix", desc: "Auto-fix detected issues" }, - { cmd: "heal", desc: "AI-driven deep healing" }, - { cmd: "audit", desc: "Run health audit without fixing" }, - { cmd: "--dry-run", desc: "Show what --fix would change without applying" }, - { cmd: "--json", desc: "Output report as JSON (CI/tooling friendly)" }, - { cmd: "--build", desc: "Include slow build health check (npm run build)" }, - { cmd: "--test", desc: "Include slow test health check (npm test)" }, - ], - dispatch: [ - { cmd: "research", desc: "Run research phase" }, - { cmd: "plan", desc: "Run planning phase" }, - { cmd: "execute", desc: "Run execution phase" }, - { cmd: "complete", desc: "Run completion phase" }, - { cmd: "reassess", desc: "Reassess current progress" }, - { cmd: "uat", desc: "Run user acceptance testing" }, - { cmd: "replan", desc: "Replan the current slice" }, - ], - rate: [ - { cmd: "over", desc: "Model was overqualified for this task" }, - { cmd: "ok", desc: "Model was appropriate for this task" }, - { cmd: "under", desc: "Model was underqualified for this task" }, - ], - workflow: [ - { cmd: "new", desc: "Create a new workflow definition (via skill)" }, - { cmd: "run", desc: "Create a run and start auto-mode" }, - { cmd: "list", desc: "List workflow runs" }, - { cmd: "validate", desc: "Validate a workflow definition YAML" }, - { cmd: "pause", desc: "Pause custom workflow auto-mode" }, - { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, - ], - codebase: [ - { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, - { cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" }, - { cmd: "generate --collapse-threshold", desc: "Generate with custom collapse threshold (default: 20)" }, - { cmd: "update", desc: "Refresh the CODEBASE.md cache immediately (preserves descriptions)" }, - { cmd: "update --max-files", desc: "Update with custom file limit" }, - { cmd: "update --collapse-threshold", desc: "Update with custom collapse threshold" }, - { cmd: "stats", desc: "Show file count, description coverage, and generation time" }, - { cmd: "help", desc: "Show usage and available subcommands" }, - ], - ship: [ - { cmd: "--dry-run", desc: "Preview PR without creating" }, - { cmd: "--draft", desc: "Open as draft PR" }, - { cmd: "--base", desc: "Override target branch (default: main)" }, - { cmd: "--force", desc: "Ship even with pending tasks" }, - ], - "session-report": [ - { cmd: "--json", desc: "Machine-readable JSON output" }, - { cmd: "--save", desc: "Save report to .gsd/reports/" }, - ], - backlog: [ - { cmd: "add", desc: "Add item to backlog" }, - { cmd: "promote", desc: "Promote backlog item to active slice" }, - { cmd: "remove", desc: "Remove backlog item" }, - ], - "pr-branch": [ - { cmd: "--dry-run", desc: "Preview what would be filtered" }, - { cmd: "--name", desc: "Custom branch name" }, - ], -}; - -function filterOptions( - partial: string, - options: readonly GsdCommandDefinition[], - prefix = "", -) { - const normalizedPrefix = prefix ? `${prefix} ` : ""; - return options - .filter((option) => option.cmd.startsWith(partial)) - .map((option) => ({ - value: `${normalizedPrefix}${option.cmd}`, - label: option.cmd, - description: option.desc, - })); -} - -function getExtensionCompletions(prefix: string, action: string) { - try { - const extDir = join(gsdHome, "agent", "extensions"); - const ids: Array<{ id: string; name: string }> = []; - for (const entry of readdirSync(extDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const manifestPath = join(extDir, entry.name, "extension-manifest.json"); - if (!existsSync(manifestPath)) continue; - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - if (typeof manifest?.id === "string") { - ids.push({ id: manifest.id, name: manifest.name ?? manifest.id }); - } - } catch { - // ignore malformed manifests - } - } - return ids - .filter((entry) => entry.id.startsWith(prefix)) - .map((entry) => ({ - value: `extensions ${action} ${entry.id}`, - label: entry.id, - description: entry.name, - })); - } catch { - return []; - } -} - -export function getGsdArgumentCompletions(prefix: string) { - const hasTrailingSpace = prefix.endsWith(" "); - const parts = prefix.trim().split(/\s+/); - if (hasTrailingSpace && parts.length >= 1) { - parts.push(""); - } - - if (parts.length <= 1) { - return filterOptions(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); - } - - const [command, subcommand = "", third = ""] = parts; - - if (command === "cmux") { - if (parts.length <= 2) { - return filterOptions(subcommand, [ - { cmd: "status", desc: "Show cmux detection, prefs, and capabilities" }, - { cmd: "on", desc: "Enable cmux integration" }, - { cmd: "off", desc: "Disable cmux integration" }, - { cmd: "notifications", desc: "Toggle cmux desktop notifications" }, - { cmd: "sidebar", desc: "Toggle cmux sidebar metadata" }, - { cmd: "splits", desc: "Toggle cmux visual subagent splits" }, - { cmd: "browser", desc: "Toggle future browser integration flag" }, - ], "cmux"); - } - if (parts.length <= 3 && ["notifications", "sidebar", "splits", "browser"].includes(subcommand)) { - return filterOptions(third, [ - { cmd: "on", desc: "Enable this cmux area" }, - { cmd: "off", desc: "Disable this cmux area" }, - ], `cmux ${subcommand}`); - } - return []; - } - - if (command === "templates" && subcommand === "info" && parts.length <= 3) { - try { - const registry = loadRegistry(); - return Object.entries(registry.templates) - .filter(([id]) => id.startsWith(third)) - .map(([id, entry]) => ({ - value: `templates info ${id}`, - label: id, - description: entry.description, - })); - } catch { - return []; - } - } - - if (command === "extensions" && parts.length === 3 && ["enable", "disable", "info"].includes(subcommand)) { - return getExtensionCompletions(third, subcommand); - } - - if (command === "undo" && parts.length <= 2) { - return [{ value: "undo --force", label: "--force", description: "Skip confirmation prompt" }]; - } - - // Workflow definition-name completion for `workflow run <name>` and `workflow validate <name>` - if (command === "workflow" && (subcommand === "run" || subcommand === "validate") && parts.length <= 3) { - try { - const defsDir = join(resolveProjectRoot(process.cwd()), ".gsd", "workflow-defs"); - if (existsSync(defsDir)) { - return readdirSync(defsDir) - .filter((f) => f.endsWith(".yaml") && f.startsWith(third)) - .map((f) => { - const name = f.replace(/\.yaml$/, ""); - return { - value: `workflow ${subcommand} ${name}`, - label: name, - description: `Workflow definition: ${name}`, - }; - }); - } - } catch { - // ignore filesystem errors during completion - } - return []; - } - - const nested = NESTED_COMPLETIONS[command]; - if (nested && parts.length <= 2) { - return filterOptions(subcommand, nested, command); - } - - return []; -} diff --git a/src/resources/extensions/gsd/commands/context.ts b/src/resources/extensions/gsd/commands/context.ts deleted file mode 100644 index 6868fdb47..000000000 --- a/src/resources/extensions/gsd/commands/context.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { checkRemoteAutoSession, isAutoActive, isAutoPaused, stopAutoRemote } from "../auto.js"; -import { validateDirectory } from "../validate-directory.js"; -import { resolveProjectRoot } from "../worktree.js"; -import { showNextAction } from "../../shared/tui.js"; -import { handleStatus } from "./handlers/core.js"; - -export interface GsdDispatchContext { - ctx: ExtensionCommandContext; - pi: ExtensionAPI; - trimmed: string; -} - -/** - * Typed error for when SF is run outside a valid project directory. - * Command handlers catch this to show a friendly message instead of a raw exception. - */ -export class GSDNoProjectError extends Error { - constructor(reason: string) { - super(reason); - this.name = "GSDNoProjectError"; - } -} - -export function projectRoot(): string { - let cwd: string; - try { - cwd = process.cwd(); - } catch { - // cwd directory was deleted (e.g. worktree teardown) — fall back to HOME (#3598) - cwd = process.env.HOME ?? "/"; - } - const root = resolveProjectRoot(cwd); - const pathToCheck = root !== cwd ? cwd : root; - const result = validateDirectory(pathToCheck); - if (result.severity === "blocked") { - throw new GSDNoProjectError(result.reason ?? "SF must be run inside a project directory."); - } - return root; -} - -export async function guardRemoteSession( - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<boolean> { - if (isAutoActive() || isAutoPaused()) return true; - - const remote = checkRemoteAutoSession(projectRoot()); - if (!remote.running || !remote.pid) return true; - - const unitLabel = remote.unitType && remote.unitId - ? `${remote.unitType} (${remote.unitId})` - : "unknown unit"; - - // In RPC/web bridge mode, interactive TUI prompts (showNextAction) block - // forever because there is no terminal to answer them. Notify and bail. - if (process.env.SF_WEB_BRIDGE_TUI === "1") { - ctx.ui.notify( - `Another auto-mode session (PID ${remote.pid}) is running on this project (${unitLabel}). ` + - `Stop it first with /gsd stop, or use /gsd steer to redirect it.`, - "warning", - ); - return false; - } - - const choice = await showNextAction(ctx, { - title: `Auto-mode is running in another terminal (PID ${remote.pid})`, - summary: [ - `Currently executing: ${unitLabel}`, - ...(remote.startedAt ? [`Started: ${remote.startedAt}`] : []), - ], - actions: [ - { - id: "status", - label: "View status", - description: "Show the current SF progress dashboard.", - recommended: true, - }, - { - id: "steer", - label: "Steer the session", - description: "Use /gsd steer <instruction> to redirect the running session.", - }, - { - id: "stop", - label: "Stop remote session", - description: `Send SIGTERM to PID ${remote.pid} to stop it gracefully.`, - }, - { - id: "force", - label: "Force start (steal lock)", - description: "Start a new session, terminating the existing one.", - }, - ], - notYetMessage: "Run /gsd when ready.", - }); - - if (choice === "status") { - await handleStatus(ctx); - return false; - } - if (choice === "steer") { - ctx.ui.notify( - "Use /gsd steer <instruction> to redirect the running auto-mode session.\n" + - "Example: /gsd steer Use Postgres instead of SQLite", - "info", - ); - return false; - } - if (choice === "stop") { - const result = stopAutoRemote(projectRoot()); - if (result.found) { - ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info"); - } else if (result.error) { - ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error"); - } else { - ctx.ui.notify("Remote session is no longer running.", "info"); - } - return false; - } - - return choice === "force"; -} - diff --git a/src/resources/extensions/gsd/commands/dispatcher.ts b/src/resources/extensions/gsd/commands/dispatcher.ts deleted file mode 100644 index 389380a5e..000000000 --- a/src/resources/extensions/gsd/commands/dispatcher.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { GSDNoProjectError } from "./context.js"; -import { handleAutoCommand } from "./handlers/auto.js"; -import { handleCoreCommand } from "./handlers/core.js"; -import { handleOpsCommand } from "./handlers/ops.js"; -import { handleParallelCommand } from "./handlers/parallel.js"; -import { handleWorkflowCommand } from "./handlers/workflow.js"; - -export async function handleGSDCommand( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<void> { - const trimmed = (typeof args === "string" ? args : "").trim(); - - const handlers = [ - () => handleCoreCommand(trimmed, ctx, pi), - () => handleAutoCommand(trimmed, ctx, pi), - () => handleParallelCommand(trimmed, ctx, pi), - () => handleWorkflowCommand(trimmed, ctx, pi), - () => handleOpsCommand(trimmed, ctx, pi), - ]; - - try { - for (const handler of handlers) { - if (await handler()) { - return; - } - } - } catch (err) { - if (err instanceof GSDNoProjectError) { - ctx.ui.notify( - `${err.message} \`cd\` into a project directory first.`, - "warning", - ); - return; - } - throw err; - } - - ctx.ui.notify(`Unknown: /gsd ${trimmed}. Run /gsd help for available commands.`, "warning"); -} diff --git a/src/resources/extensions/gsd/commands/handlers/auto.ts b/src/resources/extensions/gsd/commands/handlers/auto.ts deleted file mode 100644 index 88c1cc7a7..000000000 --- a/src/resources/extensions/gsd/commands/handlers/auto.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { enableDebug } from "../../debug-logger.js"; -import { getAutoDashboardData, isAutoActive, isAutoPaused, pauseAuto, startAutoDetached, stopAuto, stopAutoRemote } from "../../auto.js"; -import { handleRate } from "../../commands-rate.js"; -import { guardRemoteSession, projectRoot } from "../context.js"; -import { findMilestoneIds } from "../../milestone-id-utils.js"; - -/** - * Parse --yolo flag and optional file path from the auto command string. - * Supports: `/gsd auto --yolo path/to/file.md` or `/gsd auto -y path/to/file.md` - */ -function parseYoloFlag(trimmed: string): { yoloSeedFile: string | null; rest: string } { - const yoloRe = /(?:--yolo|-y)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)/; - const match = trimmed.match(yoloRe); - if (!match) return { yoloSeedFile: null, rest: trimmed }; - - // Strip quotes if present - let filePath = match[1]; - if ((filePath.startsWith('"') && filePath.endsWith('"')) || - (filePath.startsWith("'") && filePath.endsWith("'"))) { - filePath = filePath.slice(1, -1); - } - - const rest = trimmed.replace(match[0], "").replace(/\s+/g, " ").trim(); - return { yoloSeedFile: filePath, rest }; -} - -/** - * Extract a milestone ID (e.g. M016 or M001-a3b4c5) from the command string. - * Returns the matched ID and the remaining string with the ID removed. - * The milestone ID pattern matches the format used by findMilestoneIds: M\d+ with - * an optional -[a-z0-9]{6} suffix for unique milestone IDs. - */ -export function parseMilestoneTarget(input: string): { milestoneId: string | null; rest: string } { - const match = input.match(/\b(M\d+(?:-[a-z0-9]{6})?)\b/); - if (!match) return { milestoneId: null, rest: input }; - const rest = input.replace(match[0], "").replace(/\s+/g, " ").trim(); - return { milestoneId: match[1], rest }; -} - -export async function handleAutoCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { - if (trimmed === "next" || trimmed.startsWith("next ")) { - if (trimmed.includes("--dry-run")) { - const { handleDryRun } = await import("../../commands-maintenance.js"); - await handleDryRun(ctx, projectRoot()); - return true; - } - const { milestoneId, rest: afterMilestone } = parseMilestoneTarget(trimmed); - const verboseMode = afterMilestone.includes("--verbose"); - const debugMode = afterMilestone.includes("--debug"); - if (debugMode) enableDebug(projectRoot()); - if (!(await guardRemoteSession(ctx, pi))) return true; - - // Validate the milestone target exists and is not already complete. - if (milestoneId) { - const allIds = findMilestoneIds(projectRoot()); - if (!allIds.includes(milestoneId)) { - ctx.ui.notify(`Milestone ${milestoneId} does not exist. Available: ${allIds.join(", ") || "(none)"}`, "error"); - return true; - } - } - - startAutoDetached(ctx, pi, projectRoot(), verboseMode, { - step: true, - milestoneLock: milestoneId, - }); - return true; - } - - if (trimmed === "auto" || trimmed.startsWith("auto ")) { - const { yoloSeedFile, rest: afterYolo } = parseYoloFlag(trimmed); - const { milestoneId, rest: afterMilestone } = parseMilestoneTarget(afterYolo); - const verboseMode = afterMilestone.includes("--verbose"); - const debugMode = afterMilestone.includes("--debug"); - if (debugMode) enableDebug(projectRoot()); - if (!(await guardRemoteSession(ctx, pi))) return true; - - // Validate the milestone target exists and is not already complete. - if (milestoneId) { - const allIds = findMilestoneIds(projectRoot()); - if (!allIds.includes(milestoneId)) { - ctx.ui.notify(`Milestone ${milestoneId} does not exist. Available: ${allIds.join(", ") || "(none)"}`, "error"); - return true; - } - } - - if (yoloSeedFile) { - const resolved = resolve(projectRoot(), yoloSeedFile); - if (!existsSync(resolved)) { - ctx.ui.notify(`Yolo seed file not found: ${resolved}`, "error"); - return true; - } - const seedContent = readFileSync(resolved, "utf-8").trim(); - if (!seedContent) { - ctx.ui.notify(`Yolo seed file is empty: ${resolved}`, "error"); - return true; - } - // Headless path: bootstrap project, dispatch non-interactive discuss, - // then auto-mode starts automatically via checkAutoStartAfterDiscuss - // when the LLM says "Milestone X ready." - const { showHeadlessMilestoneCreation } = await import("../../guided-flow.js"); - await showHeadlessMilestoneCreation(ctx, pi, projectRoot(), seedContent); - } else if (milestoneId) { - startAutoDetached(ctx, pi, projectRoot(), verboseMode, { - milestoneLock: milestoneId, - }); - } else { - startAutoDetached(ctx, pi, projectRoot(), verboseMode); - } - return true; - } - - if (trimmed === "stop") { - if (!isAutoActive() && !isAutoPaused()) { - const result = stopAutoRemote(projectRoot()); - if (result.found) { - ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info"); - } else if (result.error) { - ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error"); - } else { - ctx.ui.notify("Auto-mode is not running.", "info"); - } - return true; - } - await stopAuto(ctx, pi, "User requested stop"); - return true; - } - - if (trimmed === "pause") { - if (!isAutoActive()) { - if (isAutoPaused()) { - ctx.ui.notify("Auto-mode is already paused. /gsd auto to resume.", "info"); - } else { - ctx.ui.notify("Auto-mode is not running.", "info"); - } - return true; - } - await pauseAuto(ctx, pi); - return true; - } - - if (trimmed === "rate" || trimmed.startsWith("rate ")) { - await handleRate(trimmed.replace(/^rate\s*/, "").trim(), ctx, projectRoot()); - return true; - } - - if (trimmed === "") { - if (!(await guardRemoteSession(ctx, pi))) return true; - startAutoDetached(ctx, pi, projectRoot(), false, { step: true }); - return true; - } - - return false; -} diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts deleted file mode 100644 index faa7bfd94..000000000 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@sf-run/pi-coding-agent"; -import type { Model } from "@sf-run/pi-ai"; -import type { GSDState } from "../../types.js"; - -import { computeProgressScore, formatProgressLine } from "../../progress-score.js"; -import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath, getProjectGSDPreferencesPath } from "../../preferences.js"; -import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard } from "../../commands-prefs-wizard.js"; -import { runEnvironmentChecks } from "../../doctor-environment.js"; -import { deriveState } from "../../state.js"; -import { handleCmux } from "../../commands-cmux.js"; -import { setSessionModelOverride } from "../../session-model-override.js"; -import { projectRoot } from "../context.js"; -import { formattedShortcutPair } from "../../shortcut-defs.js"; - -export function showHelp(ctx: ExtensionCommandContext, args = ""): void { - const summaryLines = [ - "SF — Singularity Forge\n", - "QUICK START", - " /gsd start <tpl> Start a workflow template", - " /gsd Run next unit (same as /gsd next)", - " /gsd auto Run all queued units continuously", - " /gsd pause Pause auto-mode", - " /gsd stop Stop auto-mode gracefully", - "", - "VISIBILITY", - ` /gsd status Dashboard (${formattedShortcutPair("dashboard")})`, - ` /gsd parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, - ` /gsd notifications Notification history (${formattedShortcutPair("notifications")})`, - " /gsd visualize Interactive 10-tab TUI", - " /gsd queue Show queued/dispatched units", - "", - "COURSE CORRECTION", - " /gsd steer <desc> Apply user override to active work", - " /gsd capture <text> Quick-capture a thought to CAPTURES.md", - " /gsd triage Classify and route pending captures", - " /gsd undo Revert last completed unit [--force]", - " /gsd rethink Conversational project reorganization", - "", - "SETUP", - " /gsd init Project init wizard", - " /gsd setup Global setup status [llm|search|remote|keys|prefs]", - " /gsd model Switch active session model", - " /gsd prefs Manage preferences", - " /gsd doctor Diagnose and repair .gsd/ state", - "", - "Use /gsd help full for the complete command reference.", - ]; - - const fullLines = [ - "SF — Singularity Forge\n", - "WORKFLOW", - " /gsd start <tpl> Start a workflow template (bugfix, spike, feature, hotfix, etc.)", - " /gsd templates List available workflow templates [info <name>]", - " /gsd Run next unit in step mode (same as /gsd next)", - " /gsd next Execute next task, then pause [--dry-run] [--verbose]", - " /gsd auto Run all queued units continuously [--verbose]", - " /gsd stop Stop auto-mode gracefully", - " /gsd pause Pause auto-mode (preserves state, /gsd auto to resume)", - " /gsd discuss Start guided milestone/slice discussion", - " /gsd new-milestone Create milestone from headless context (used by gsd headless)", - "", - "VISIBILITY", - ` /gsd status Show progress dashboard (${formattedShortcutPair("dashboard")})`, - ` /gsd parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, - " /gsd visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", - " /gsd queue Show queued/dispatched units and execution order", - " /gsd history View execution history [--cost] [--phase] [--model] [N]", - " /gsd changelog Show categorized release notes [version]", - ` /gsd notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`, - "", - "COURSE CORRECTION", - " /gsd steer <desc> Apply user override to active work", - " /gsd capture <text> Quick-capture a thought to CAPTURES.md", - " /gsd triage Classify and route pending captures", - " /gsd skip <unit> Prevent a unit from auto-mode dispatch", - " /gsd undo Revert last completed unit [--force]", - " /gsd rethink Conversational project reorganization — reorder, park, discard, add milestones", - " /gsd park [id] Park a milestone — skip without deleting [reason]", - " /gsd unpark [id] Reactivate a parked milestone", - "", - "PROJECT KNOWLEDGE", - " /gsd knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md", - " /gsd codebase [generate|update|stats] Manage the CODEBASE.md cache used in prompt context", - "", - "SETUP & CONFIGURATION", - " /gsd init Project init wizard — detect, configure, bootstrap .gsd/", - " /gsd setup Global setup status [llm|search|remote|keys|prefs]", - " /gsd model Switch active session model [provider/model|model-id]", - " /gsd mode Set workflow mode (solo/team) [global|project]", - " /gsd prefs Manage preferences [global|project|status|wizard|setup|import-claude]", - " /gsd cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", - " /gsd config Set API keys for external tools", - " /gsd keys API key manager [list|add|remove|test|rotate|doctor]", - " /gsd show-config Show effective configuration (models, routing, toggles)", - " /gsd hooks Show post-unit hook configuration", - " /gsd extensions Manage extensions [list|enable|disable|info]", - " /gsd fast Toggle OpenAI service tier [on|off|flex|status]", - " /gsd mcp MCP server status and connectivity [status|check <server>|init [dir]]", - "", - "MAINTENANCE", - " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", - " /gsd export Export milestone/slice results [--json|--markdown|--html] [--all]", - " /gsd cleanup Remove merged branches or snapshots [branches|snapshots]", - " /gsd migrate Migrate .planning/ (v1) to .gsd/ (v2) format", - " /gsd remote Control remote auto-mode [slack|discord|status|disconnect]", - " /gsd inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", - " /gsd update Update SF to the latest version via npm", - ]; - const full = ["full", "--full", "all"].includes(args.trim().toLowerCase()); - ctx.ui.notify((full ? fullLines : summaryLines).join("\n"), "info"); -} - -export async function handleStatus(ctx: ExtensionCommandContext): Promise<void> { - const basePath = projectRoot(); - // Open DB in cold sessions so status uses DB-backed state, not filesystem fallback (#3385) - const { ensureDbOpen } = await import("../../bootstrap/dynamic-tools.js"); - await ensureDbOpen(); - const state = await deriveState(basePath); - - if (state.registry.length === 0) { - ctx.ui.notify("No SF milestones found. Run /gsd to start.", "info"); - return; - } - - const { GSDDashboardOverlay } = await import("../../dashboard-overlay.js"); - const result = await ctx.ui.custom<boolean>( - (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions: { - width: "90%", - minWidth: 80, - maxHeight: "92%", - anchor: "center", - }, - }, - ); - - if (result === undefined) { - ctx.ui.notify(formatTextStatus(state), "info"); - } -} - -export async function fireStatusViaCommand(ctx: ExtensionContext): Promise<void> { - await handleStatus(ctx as ExtensionCommandContext); -} - -export async function handleVisualize(ctx: ExtensionCommandContext): Promise<void> { - if (!ctx.hasUI) { - ctx.ui.notify("Visualizer requires an interactive terminal.", "warning"); - return; - } - - const { GSDVisualizerOverlay } = await import("../../visualizer-overlay.js"); - const result = await ctx.ui.custom<boolean>( - (tui, theme, _kb, done) => new GSDVisualizerOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions: { - width: "80%", - minWidth: 80, - maxHeight: "90%", - anchor: "center", - }, - }, - ); - - if (result === undefined) { - ctx.ui.notify("Visualizer requires an interactive terminal. Use /gsd status for a text-based overview.", "warning"); - } -} - -export async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<void> { - const { detectProjectState, hasGlobalSetup } = await import("../../detection.js"); - - const globalConfigured = hasGlobalSetup(); - const detection = detectProjectState(projectRoot()); - - const statusLines = ["SF Setup Status\n"]; - statusLines.push(` Global preferences: ${globalConfigured ? "configured" : "not set"}`); - statusLines.push(` Project state: ${detection.state}`); - if (detection.projectSignals.primaryLanguage) { - statusLines.push(` Detected: ${detection.projectSignals.primaryLanguage}`); - } - - if (args === "llm" || args === "auth") { - ctx.ui.notify("Use /login to configure LLM authentication.", "info"); - return; - } - if (args === "search") { - ctx.ui.notify("Use /search-provider to configure web search.", "info"); - return; - } - if (args === "remote") { - ctx.ui.notify("Use /gsd remote to configure remote questions.", "info"); - return; - } - if (args === "keys") { - const { handleKeys } = await import("../../key-manager.js"); - await handleKeys("", ctx); - return; - } - if (args === "prefs") { - await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); - await handlePrefsWizard(ctx, "global"); - return; - } - - ctx.ui.notify(statusLines.join("\n"), "info"); - ctx.ui.notify( - "Available setup commands:\n" + - " /gsd setup llm — LLM authentication\n" + - " /gsd setup search — Web search provider\n" + - " /gsd setup remote — Remote questions (Discord/Slack/Telegram)\n" + - " /gsd setup keys — Tool API keys\n" + - " /gsd setup prefs — Global preferences wizard", - "info", - ); -} - -function sortModelsForSelection(models: Model<any>[], currentModel: Model<any> | undefined): Model<any>[] { - return [...models].sort((a, b) => { - const aCurrent = currentModel && a.provider === currentModel.provider && a.id === currentModel.id; - const bCurrent = currentModel && b.provider === currentModel.provider && b.id === currentModel.id; - if (aCurrent && !bCurrent) return -1; - if (!aCurrent && bCurrent) return 1; - const providerCmp = a.provider.localeCompare(b.provider); - if (providerCmp !== 0) return providerCmp; - return a.id.localeCompare(b.id); - }); -} - -function buildProviderModelGroups( - models: Model<any>[], - currentModel: Model<any> | undefined, -): Map<string, Model<any>[]> { - const byProvider = new Map<string, Model<any>[]>(); - - for (const model of sortModelsForSelection(models, currentModel)) { - let group = byProvider.get(model.provider); - if (!group) { - group = []; - byProvider.set(model.provider, group); - } - group.push(model); - } - return byProvider; -} - -async function selectModelByProvider( - title: string, - models: Model<any>[], - ctx: ExtensionCommandContext, - currentModel: Model<any> | undefined, -): Promise<Model<any> | undefined> { - const byProvider = buildProviderModelGroups(models, currentModel); - const providerOptions = Array.from(byProvider.entries()).map(([provider, group]) => - `${provider} (${group.length} model${group.length === 1 ? "" : "s"})`, - ); - providerOptions.push("(cancel)"); - - const providerChoice = await ctx.ui.select(`${title} — choose provider:`, providerOptions); - if (!providerChoice || typeof providerChoice !== "string" || providerChoice === "(cancel)") return undefined; - - const providerName = providerChoice.replace(/ \(\d+ models?\)$/, ""); - const providerModels = byProvider.get(providerName); - if (!providerModels || providerModels.length === 0) return undefined; - - const optionToModel = new Map<string, Model<any>>(); - const modelOptions = providerModels.map((model) => { - const isCurrent = currentModel && model.provider === currentModel.provider && model.id === currentModel.id; - const label = `${isCurrent ? "* " : ""}${model.id}`; - optionToModel.set(label, model); - return label; - }); - modelOptions.push("(cancel)"); - - const modelChoice = await ctx.ui.select(`${title} — ${providerName}:`, modelOptions); - if (!modelChoice || typeof modelChoice !== "string" || modelChoice === "(cancel)") return undefined; - return optionToModel.get(modelChoice); -} - -async function resolveRequestedModel( - query: string, - ctx: ExtensionCommandContext, -): Promise<Model<any> | undefined> { - const { resolveModelId } = await import("../../auto-model-selection.js"); - const models = ctx.modelRegistry.getAvailable(); - const exact = resolveModelId(query, models, ctx.model?.provider); - if (exact) return exact; - - const lowerQuery = query.toLowerCase(); - const partialMatches = models.filter((model) => - model.id.toLowerCase().includes(lowerQuery) - || `${model.provider}/${model.id}`.toLowerCase().includes(lowerQuery), - ); - - if (partialMatches.length === 1) return partialMatches[0]; - if (partialMatches.length === 0 || !ctx.hasUI) return undefined; - return selectModelByProvider(`Multiple models match "${query}"`, partialMatches, ctx, ctx.model); -} - -async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi: ExtensionAPI | undefined): Promise<void> { - const availableModels = ctx.modelRegistry.getAvailable(); - if (availableModels.length === 0) { - ctx.ui.notify("No available models found. Check provider auth and model discovery.", "warning"); - return; - } - if (!pi) { - ctx.ui.notify("Model switching is unavailable in this context.", "warning"); - return; - } - - const trimmed = trimmedArgs.trim(); - let targetModel: Model<any> | undefined; - - if (!trimmed) { - if (!ctx.hasUI) { - const current = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "(none)"; - ctx.ui.notify(`Current model: ${current}\nUsage: /gsd model <provider/model|model-id>`, "info"); - return; - } - - targetModel = await selectModelByProvider("Select session model:", availableModels, ctx, ctx.model); - } else { - targetModel = await resolveRequestedModel(trimmed, ctx); - } - - if (!targetModel) { - ctx.ui.notify(`Model "${trimmed}" not found. Use /gsd model with an exact provider/model or a unique model ID.`, "warning"); - return; - } - - const ok = await pi.setModel(targetModel); - if (!ok) { - ctx.ui.notify(`No API key for ${targetModel.provider}/${targetModel.id}`, "warning"); - return; - } - - // /gsd model is an explicit per-session pin for SF dispatches. - // This is captured at auto bootstrap so it survives internal session - // switches during /gsd auto and /gsd next runs. - const sessionId = ctx.sessionManager?.getSessionId?.(); - if (sessionId) { - setSessionModelOverride(sessionId, { - provider: targetModel.provider, - id: targetModel.id, - }); - } - - ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info"); -} - -export async function handleCoreCommand( - trimmed: string, - ctx: ExtensionCommandContext, - pi?: ExtensionAPI, -): Promise<boolean> { - if (trimmed === "help" || trimmed === "h" || trimmed === "?" || trimmed.startsWith("help ")) { - showHelp(ctx, trimmed.startsWith("help ") ? trimmed.slice(5).trim() : ""); - return true; - } - if (trimmed === "status") { - await handleStatus(ctx); - return true; - } - if (trimmed === "visualize") { - await handleVisualize(ctx); - return true; - } - if (trimmed === "widget" || trimmed.startsWith("widget ")) { - const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("../../auto-dashboard.js"); - const arg = trimmed.replace(/^widget\s*/, "").trim(); - if (arg === "full" || arg === "small" || arg === "min" || arg === "off") { - setWidgetMode(arg); - } else { - cycleWidgetMode(); - } - ctx.ui.notify(`Widget: ${getWidgetMode()}`, "info"); - return true; - } - if (trimmed === "model" || trimmed.startsWith("model ")) { - await handleModel(trimmed.replace(/^model\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "mode" || trimmed.startsWith("mode ")) { - const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); - const scope = modeArgs === "project" ? "project" : "global"; - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - await ensurePreferencesFile(path, ctx, scope); - await handlePrefsMode(ctx, scope); - return true; - } - if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { - await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "cmux" || trimmed.startsWith("cmux ")) { - await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "show-config") { - const { GSDConfigOverlay, formatConfigText } = await import("../../config-overlay.js"); - const result = await ctx.ui.custom<boolean>( - (tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions: { - width: "65%", - minWidth: 55, - maxHeight: "85%", - anchor: "center", - }, - }, - ); - if (result === undefined) { - ctx.ui.notify(formatConfigText(), "info"); - } - return true; - } - if (trimmed === "setup" || trimmed.startsWith("setup ")) { - await handleSetup(trimmed.replace(/^setup\s*/, "").trim(), ctx); - return true; - } - return false; -} - -export function formatTextStatus(state: GSDState): string { - const lines: string[] = ["SF Status\n"]; - lines.push(formatProgressLine(computeProgressScore())); - lines.push(""); - lines.push(`Phase: ${state.phase}`); - - if (state.activeMilestone) { - lines.push(`Active milestone: ${state.activeMilestone.id} — ${state.activeMilestone.title}`); - } - if (state.activeSlice) { - lines.push(`Active slice: ${state.activeSlice.id} — ${state.activeSlice.title}`); - } - if (state.activeTask) { - lines.push(`Active task: ${state.activeTask.id} — ${state.activeTask.title}`); - } - if (state.progress) { - const { milestones, slices, tasks } = state.progress; - const parts: string[] = [`milestones ${milestones.done}/${milestones.total}`]; - if (slices) parts.push(`slices ${slices.done}/${slices.total}`); - if (tasks) parts.push(`tasks ${tasks.done}/${tasks.total}`); - lines.push(`Progress: ${parts.join(", ")}`); - } - if (state.nextAction) { - lines.push(`Next: ${state.nextAction}`); - } - if (state.blockers.length > 0) { - lines.push(`Blockers: ${state.blockers.join("; ")}`); - } - if (state.registry.length > 0) { - lines.push(""); - lines.push("Milestones:"); - for (const milestone of state.registry) { - const icon = milestone.status === "complete" - ? "✓" - : milestone.status === "active" - ? "▶" - : milestone.status === "parked" - ? "⏸" - : "○"; - lines.push(` ${icon} ${milestone.id}: ${milestone.title} (${milestone.status})`); - } - } - - const envResults = runEnvironmentChecks(projectRoot()); - const envIssues = envResults.filter((result) => result.status !== "ok"); - if (envIssues.length > 0) { - lines.push(""); - lines.push("Environment:"); - for (const issue of envIssues) { - lines.push(` ${issue.status === "error" ? "✗" : "⚠"} ${issue.message}`); - } - } - - return lines.join("\n"); -} diff --git a/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts b/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts deleted file mode 100644 index 1e5b78976..000000000 --- a/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts +++ /dev/null @@ -1,150 +0,0 @@ -// SF Extension — /gsd notifications Command Handler -// View, filter, and clear the persistent notification history. - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { - readNotifications, - clearNotifications, - getUnreadCount, - suppressPersistence, - unsuppressPersistence, - type NotifySeverity, -} from "../../notification-store.js"; -import { GSDNotificationOverlay } from "../../notification-overlay.js"; - -const MAX_INLINE_ENTRIES = 40; - -function severityIcon(severity: NotifySeverity): string { - switch (severity) { - case "error": return "✗"; - case "warning": return "⚠"; - case "success": return "✓"; - case "info": - default: return "●"; - } -} - -function formatTimestamp(ts: string): string { - try { - const d = new Date(ts); - return d.toLocaleString("en-US", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); - } catch { - return ts.slice(0, 19); - } -} - -export async function handleNotificationsCommand( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<boolean> { - // /gsd notifications clear - if (args === "clear") { - clearNotifications(); - // Suppress persistence so the confirmation toast doesn't re-populate the store - suppressPersistence(); - try { - ctx.ui.notify("All notifications cleared.", "success"); - } finally { - unsuppressPersistence(); - } - return true; - } - - // /gsd notifications tail [N] - if (args === "tail" || args.startsWith("tail ")) { - const countStr = args.replace(/^tail\s*/, "").trim(); - const count = countStr ? parseInt(countStr, 10) : 20; - const all = readNotifications(); - const n = isNaN(count) || count < 1 ? 20 : Math.min(count, MAX_INLINE_ENTRIES); - const entries = all.slice(0, n); - - if (entries.length === 0) { - ctx.ui.notify("No notifications.", "info"); - return true; - } - - const lines = entries.map((e) => - `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, - ); - const suffix = all.length > entries.length - ? `\n... and ${all.length - entries.length} more (open /gsd notifications to browse all)` - : ""; - ctx.ui.notify(`Last ${entries.length} notification(s):\n${lines.join("\n")}${suffix}`, "info"); - return true; - } - - // /gsd notifications filter <severity> - if (args.startsWith("filter ")) { - const severity = args.replace(/^filter\s+/, "").trim().toLowerCase(); - if (!["error", "warning", "info", "success"].includes(severity)) { - ctx.ui.notify("Usage: /gsd notifications filter <error|warning|info|success>", "warning"); - return true; - } - const entries = readNotifications().filter((e) => e.severity === severity); - - if (entries.length === 0) { - ctx.ui.notify(`No ${severity} notifications.`, "info"); - return true; - } - - const lines = entries.slice(0, 20).map((e) => - `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, - ); - const suffix = entries.length > 20 - ? `\n... and ${entries.length - 20} more (open /gsd notifications to browse all)` - : ""; - ctx.ui.notify(`${severity} notifications (${entries.length}):\n${lines.join("\n")}${suffix}`, "info"); - return true; - } - - // /gsd notifications (no args) — open overlay in TUI, or print summary - if (args === "" || args === "status") { - // Try overlay first (TUI mode) - if (ctx.hasUI) { - try { - const result = await ctx.ui.custom<boolean>( - (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)), - { - overlay: true, - overlayOptions: { - width: "80%", - minWidth: 60, - maxHeight: "88%", - anchor: "center", - backdrop: true, - }, - }, - ); - if (result !== undefined) { - return true; - } - } catch { - // Fall through to text output if overlay fails - } - } - - // Text fallback (RPC/headless mode) - const unread = getUnreadCount(); - const entries = readNotifications().slice(0, 10); - if (entries.length === 0) { - ctx.ui.notify("No notifications.", "info"); - return true; - } - - const lines = entries.map((e) => - `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, - ); - const header = unread > 0 ? `${unread} unread — ` : ""; - ctx.ui.notify(`${header}Recent notifications:\n${lines.join("\n")}`, "info"); - return true; - } - - // Unknown subcommand - ctx.ui.notify( - "Usage: /gsd notifications [clear|tail [N]|filter <severity>]", - "warning", - ); - return true; -} diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts deleted file mode 100644 index 1880c55ae..000000000 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { enableDebug } from "../../debug-logger.js"; -import { dispatchDirectPhase } from "../../auto-direct-dispatch.js"; -import { handleConfig } from "../../commands-config.js"; -import { handleDoctor, handleCapture, handleKnowledge, handleRunHook, handleSkillHealth, handleSteer, handleTriage, handleUpdate } from "../../commands-handlers.js"; -import { handleInspect } from "../../commands-inspect.js"; -import { handleLogs } from "../../commands-logs.js"; -import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleCleanupProjects, handleCleanupWorktrees, handleRecover } from "../../commands-maintenance.js"; -import { handleExport } from "../../export.js"; -import { handleHistory } from "../../history.js"; -import { handleUndo } from "../../undo.js"; -import { handleRemote } from "../../../remote-questions/mod.js"; -import { handleShip } from "../../commands-ship.js"; -import { handleSessionReport } from "../../commands-session-report.js"; -import { handlePrBranch } from "../../commands-pr-branch.js"; -import { projectRoot } from "../context.js"; - -export async function handleOpsCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { - if (trimmed === "init") { - const { detectProjectState } = await import("../../detection.js"); - const { handleReinit, showProjectInit } = await import("../../init-wizard.js"); - const basePath = projectRoot(); - const detection = detectProjectState(basePath); - if (detection.state === "v2-gsd" || detection.state === "v2-gsd-empty") { - await handleReinit(ctx, detection); - } else { - await showProjectInit(ctx, pi, basePath, detection); - } - return true; - } - if (trimmed === "keys" || trimmed.startsWith("keys ")) { - const { handleKeys } = await import("../../key-manager.js"); - await handleKeys(trimmed.replace(/^keys\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "doctor" || trimmed.startsWith("doctor ")) { - await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "logs" || trimmed.startsWith("logs ")) { - await handleLogs(trimmed.replace(/^logs\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "forensics" || trimmed.startsWith("forensics ")) { - const { handleForensics } = await import("../../forensics.js"); - await handleForensics(trimmed.replace(/^forensics\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "changelog" || trimmed.startsWith("changelog ")) { - const { handleChangelog } = await import("../../changelog.js"); - await handleChangelog(trimmed.replace(/^changelog\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "history" || trimmed.startsWith("history ")) { - await handleHistory(trimmed.replace(/^history\s*/, "").trim(), ctx, projectRoot()); - return true; - } - if (trimmed === "undo-task" || trimmed.startsWith("undo-task ")) { - const { handleUndoTask } = await import("../../undo.js"); - await handleUndoTask(trimmed.replace(/^undo-task\s*/, "").trim(), ctx, pi, projectRoot()); - return true; - } - if (trimmed === "reset-slice" || trimmed.startsWith("reset-slice ")) { - const { handleResetSlice } = await import("../../undo.js"); - await handleResetSlice(trimmed.replace(/^reset-slice\s*/, "").trim(), ctx, pi, projectRoot()); - return true; - } - if (trimmed === "undo" || trimmed.startsWith("undo ")) { - await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, projectRoot()); - return true; - } - if (trimmed === "skip") { - ctx.ui.notify("Usage: /gsd skip <unit-id> Example: /gsd skip M001/S01/T03", "warning"); - return true; - } - if (trimmed.startsWith("skip ")) { - await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, projectRoot()); - return true; - } - if (trimmed === "recover") { - await handleRecover(ctx, projectRoot()); - return true; - } - if (trimmed === "export" || trimmed.startsWith("export ")) { - await handleExport(trimmed.replace(/^export\s*/, "").trim(), ctx, projectRoot()); - return true; - } - if (trimmed === "cleanup projects" || trimmed.startsWith("cleanup projects ")) { - await handleCleanupProjects(trimmed.replace(/^cleanup projects\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "cleanup worktrees") { - await handleCleanupWorktrees(ctx, projectRoot()); - return true; - } - if (trimmed === "cleanup") { - await handleCleanupBranches(ctx, projectRoot()); - await handleCleanupSnapshots(ctx, projectRoot()); - return true; - } - if (trimmed === "cleanup branches") { - await handleCleanupBranches(ctx, projectRoot()); - return true; - } - if (trimmed === "cleanup snapshots") { - await handleCleanupSnapshots(ctx, projectRoot()); - return true; - } - if (trimmed.startsWith("capture ") || trimmed === "capture") { - await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "triage") { - await handleTriage(ctx, pi, process.cwd()); - return true; - } - if (trimmed === "config") { - await handleConfig(ctx); - return true; - } - if (trimmed === "hooks") { - const { formatHookStatus } = await import("../../post-unit-hooks.js"); - ctx.ui.notify(formatHookStatus(), "info"); - return true; - } - if (trimmed === "skill-health" || trimmed.startsWith("skill-health ")) { - await handleSkillHealth(trimmed.replace(/^skill-health\s*/, "").trim(), ctx); - return true; - } - if (trimmed.startsWith("run-hook ")) { - await handleRunHook(trimmed.replace(/^run-hook\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "run-hook") { - ctx.ui.notify(`Usage: /gsd run-hook <hook-name> <unit-type> <unit-id> - -Unit types: - execute-task - Task execution (unit-id: M001/S01/T01) - plan-slice - Slice planning (unit-id: M001/S01) - research-milestone - Milestone research (unit-id: M001) - complete-slice - Slice completion (unit-id: M001/S01) - complete-milestone - Milestone completion (unit-id: M001) - -Examples: - /gsd run-hook code-review execute-task M001/S01/T01 - /gsd run-hook lint-check plan-slice M001/S01`, "warning"); - return true; - } - if (trimmed.startsWith("steer ")) { - await handleSteer(trimmed.replace(/^steer\s+/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "steer") { - ctx.ui.notify("Usage: /gsd steer <description of change>. Example: /gsd steer Use Postgres instead of SQLite", "warning"); - return true; - } - if (trimmed.startsWith("knowledge ")) { - await handleKnowledge(trimmed.replace(/^knowledge\s+/, "").trim(), ctx); - return true; - } - if (trimmed === "knowledge") { - ctx.ui.notify("Usage: /gsd knowledge <rule|pattern|lesson> <description>. Example: /gsd knowledge rule Use real DB for integration tests", "warning"); - return true; - } - if (trimmed === "migrate" || trimmed.startsWith("migrate ")) { - const { handleMigrate } = await import("../../migrate/command.js"); - await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "remote" || trimmed.startsWith("remote ")) { - await handleRemote(trimmed.replace(/^remote\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "dispatch" || trimmed.startsWith("dispatch ")) { - const phase = trimmed.replace(/^dispatch\s*/, "").trim(); - if (!phase) { - ctx.ui.notify("Usage: /gsd dispatch <phase> (research|plan|execute|complete|reassess|uat|replan)", "warning"); - return true; - } - await dispatchDirectPhase(ctx, pi, phase, projectRoot()); - return true; - } - if (trimmed === "notifications" || trimmed.startsWith("notifications ")) { - const { handleNotificationsCommand } = await import("./notifications-handler.js"); - await handleNotificationsCommand(trimmed.replace(/^notifications\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "inspect") { - await handleInspect(ctx); - return true; - } - if (trimmed === "update") { - await handleUpdate(ctx); - return true; - } - if (trimmed === "fast" || trimmed.startsWith("fast ")) { - const { handleFast } = await import("../../service-tier.js"); - await handleFast(trimmed.replace(/^fast\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "mcp" || trimmed.startsWith("mcp ")) { - const { handleMcpStatus } = await import("../../commands-mcp-status.js"); - await handleMcpStatus(trimmed.replace(/^mcp\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "extensions" || trimmed.startsWith("extensions ")) { - const { handleExtensions } = await import("../../commands-extensions.js"); - await handleExtensions(trimmed.replace(/^extensions\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "rethink") { - const { handleRethink } = await import("../../rethink.js"); - await handleRethink(trimmed, ctx, pi); - return true; - } - if (trimmed === "codebase" || trimmed.startsWith("codebase ")) { - const { handleCodebase } = await import("../../commands-codebase.js"); - await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "ship" || trimmed.startsWith("ship ")) { - await handleShip(trimmed.replace(/^ship\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "session-report" || trimmed.startsWith("session-report ")) { - await handleSessionReport(trimmed.replace(/^session-report\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "pr-branch" || trimmed.startsWith("pr-branch ")) { - await handlePrBranch(trimmed.replace(/^pr-branch\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "add-tests" || trimmed.startsWith("add-tests ")) { - const { handleAddTests } = await import("../../commands-add-tests.js"); - await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "extract-learnings" || trimmed.startsWith("extract-learnings ")) { - const { handleExtractLearnings } = await import("../../commands-extract-learnings.js"); - await handleExtractLearnings(trimmed.replace(/^extract-learnings\s*/, "").trim(), ctx, pi); - return true; - } - return false; -} diff --git a/src/resources/extensions/gsd/commands/handlers/parallel.ts b/src/resources/extensions/gsd/commands/handlers/parallel.ts deleted file mode 100644 index 0fdd4ad45..000000000 --- a/src/resources/extensions/gsd/commands/handlers/parallel.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { - getOrchestratorState, - getWorkerStatuses, - isParallelActive, - pauseWorker, - prepareParallelStart, - refreshWorkerStatuses, - resumeWorker, - startParallel, - stopParallel, -} from "../../parallel-orchestrator.js"; -import { formatEligibilityReport } from "../../parallel-eligibility.js"; -import { formatMergeResults, mergeAllCompleted, mergeCompletedMilestone } from "../../parallel-merge.js"; -import { loadEffectiveGSDPreferences, resolveParallelConfig } from "../../preferences.js"; -import { projectRoot } from "../context.js"; -function emitParallelMessage(pi: ExtensionAPI, content: string): void { - pi.sendMessage({ customType: "gsd-parallel", content, display: true }); -} - -export async function handleParallelCommand(trimmed: string, _ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { - if (!trimmed.startsWith("parallel")) return false; - - const parallelArgs = trimmed.slice("parallel".length).trim(); - const [subcommand = "", ...restParts] = parallelArgs.split(/\s+/); - const rest = restParts.join(" "); - - if (subcommand === "start" || subcommand === "") { - const root = projectRoot(); - const loaded = loadEffectiveGSDPreferences(); - const config = resolveParallelConfig(loaded?.preferences); - if (!config.enabled) { - emitParallelMessage(pi, "Parallel mode is not enabled. Set `parallel.enabled: true` in your preferences."); - return true; - } - const candidates = await prepareParallelStart(root, loaded?.preferences); - const report = formatEligibilityReport(candidates); - if (candidates.eligible.length === 0) { - emitParallelMessage(pi, `${report}\n\nNo milestones are eligible for parallel execution.`); - return true; - } - const result = await startParallel( - root, - candidates.eligible.map((candidate) => candidate.milestoneId), - loaded?.preferences, - ); - const lines = ["Parallel orchestration started.", `Workers: ${result.started.join(", ")}`]; - if (result.errors.length > 0) { - lines.push(`Errors: ${result.errors.map((entry) => `${entry.mid}: ${entry.error}`).join("; ")}`); - } - emitParallelMessage(pi, `${report}\n\n${lines.join("\n")}`); - return true; - } - - if (subcommand === "status") { - const root = projectRoot(); - refreshWorkerStatuses(root, { restoreIfNeeded: true }); - const workers = getWorkerStatuses(root); - if (workers.length === 0 || !isParallelActive()) { - emitParallelMessage(pi, "No parallel orchestration is currently active."); - return true; - } - const lines = ["# Parallel Workers\n"]; - for (const worker of workers) { - lines.push(`- **${worker.milestoneId}** (${worker.title}) — ${worker.state} — $${worker.cost.toFixed(2)}`); - } - const state = getOrchestratorState(); - if (state) { - lines.push(`\nTotal cost: $${state.totalCost.toFixed(2)}`); - } - emitParallelMessage(pi, lines.join("\n")); - return true; - } - - if (subcommand === "stop") { - const milestoneId = rest.trim() || undefined; - await stopParallel(projectRoot(), milestoneId); - emitParallelMessage(pi, milestoneId ? `Stopped worker for ${milestoneId}.` : "All parallel workers stopped."); - return true; - } - - if (subcommand === "pause") { - const milestoneId = rest.trim() || undefined; - pauseWorker(projectRoot(), milestoneId); - emitParallelMessage(pi, milestoneId ? `Paused worker for ${milestoneId}.` : "All parallel workers paused."); - return true; - } - - if (subcommand === "resume") { - const milestoneId = rest.trim() || undefined; - resumeWorker(projectRoot(), milestoneId); - emitParallelMessage(pi, milestoneId ? `Resumed worker for ${milestoneId}.` : "All parallel workers resumed."); - return true; - } - - if (subcommand === "merge") { - const milestoneId = rest.trim() || undefined; - if (milestoneId) { - const result = await mergeCompletedMilestone(projectRoot(), milestoneId); - emitParallelMessage(pi, formatMergeResults([result])); - return true; - } - const workers = getWorkerStatuses(projectRoot()); - if (workers.length === 0) { - emitParallelMessage(pi, "No parallel workers to merge."); - return true; - } - const results = await mergeAllCompleted(projectRoot(), workers); - emitParallelMessage(pi, formatMergeResults(results)); - return true; - } - - if (subcommand === "watch") { - const root = projectRoot(); - const { ParallelMonitorOverlay } = await import("../../parallel-monitor-overlay.js"); - await _ctx.ui.custom<void>( - (tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(), root), - { - overlay: true, - overlayOptions: { - width: "90%", - minWidth: 80, - maxHeight: "92%", - anchor: "center", - }, - }, - ); - return true; - } - - emitParallelMessage(pi, `Unknown parallel subcommand "${subcommand}". Usage: /gsd parallel [start|status|stop|pause|resume|merge|watch]`); - return true; -} - diff --git a/src/resources/extensions/gsd/commands/handlers/workflow.ts b/src/resources/extensions/gsd/commands/handlers/workflow.ts deleted file mode 100644 index fd603d08d..000000000 --- a/src/resources/extensions/gsd/commands/handlers/workflow.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { existsSync, readFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; - -import { handleQuick } from "../../quick.js"; -import { showDiscuss, showHeadlessMilestoneCreation, showQueue } from "../../guided-flow.js"; -import { handleStart, handleTemplates } from "../../commands-workflow-templates.js"; -import { gsdRoot } from "../../paths.js"; -import { deriveState } from "../../state.js"; -import { isParked, parkMilestone, unparkMilestone } from "../../milestone-actions.js"; -import { loadEffectiveGSDPreferences } from "../../preferences.js"; -import { nextMilestoneId } from "../../milestone-ids.js"; -import { findMilestoneIds } from "../../guided-flow.js"; -import { projectRoot } from "../context.js"; -import { createRun, listRuns } from "../../run-manager.js"; -import { - setActiveEngineId, - setActiveRunDir, - startAutoDetached, - pauseAuto, - isAutoActive, - getActiveEngineId, -} from "../../auto.js"; -import { validateDefinition } from "../../definition-loader.js"; - -// ─── Custom Workflow Subcommands ───────────────────────────────────────── - -const WORKFLOW_USAGE = [ - "Usage: /gsd workflow <subcommand>", - "", - " new — Create a new workflow definition (via skill)", - " run <name> [k=v] — Create a run and start auto-mode", - " list [name] — List workflow runs (optionally filtered by name)", - " validate <name> — Validate a workflow definition YAML", - " pause — Pause custom workflow auto-mode", - " resume — Resume paused custom workflow auto-mode", -].join("\n"); - -function splitWorkflowRunArgs(input: string): string[] { - const tokens: string[] = []; - let current = ""; - let quote: '"' | "'" | null = null; - let escapeNext = false; - - for (const ch of input) { - if (escapeNext) { - current += ch; - escapeNext = false; - continue; - } - - if (ch === "\\") { - escapeNext = true; - continue; - } - - if (quote) { - if (ch === quote) { - quote = null; - } else { - current += ch; - } - continue; - } - - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - - if (/\s/.test(ch)) { - if (current) { - tokens.push(current); - current = ""; - } - continue; - } - - current += ch; - } - - if (escapeNext) current += "\\"; - if (current) tokens.push(current); - return tokens; -} - -export function parseWorkflowRunArgs(args: string): { defName: string; overrides: Record<string, string> } { - const parts = splitWorkflowRunArgs(args); - const defName = parts[0] ?? ""; - const overrides: Record<string, string> = {}; - for (let i = 1; i < parts.length; i++) { - const eqIdx = parts[i].indexOf("="); - if (eqIdx > 0) { - overrides[parts[i].slice(0, eqIdx)] = parts[i].slice(eqIdx + 1); - } - } - return { defName, overrides }; -} - -async function handleCustomWorkflow( - sub: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise<boolean> { - // Bare `/gsd workflow` — show usage - if (!sub) { - ctx.ui.notify(WORKFLOW_USAGE, "info"); - return true; - } - - // ── new ── - if (sub === "new") { - ctx.ui.notify("Use the create-workflow skill: /skill create-workflow", "info"); - return true; - } - - // ── run <name> [param=value ...] ── - if (sub === "run" || sub.startsWith("run ")) { - const args = sub.slice("run".length).trim(); - if (!args) { - ctx.ui.notify("Usage: /gsd workflow run <name> [param=value ...]", "warning"); - return true; - } - const { defName, overrides } = parseWorkflowRunArgs(args); - try { - const base = projectRoot(); - const runDir = createRun(base, defName, Object.keys(overrides).length > 0 ? overrides : undefined); - setActiveEngineId("custom"); - setActiveRunDir(runDir); - ctx.ui.notify(`Created workflow run: ${defName}\nRun dir: ${runDir}`, "info"); - startAutoDetached(ctx, pi, base, false); - } catch (err) { - // Clean up engine state so a failed workflow run doesn't pollute the next /gsd auto - setActiveEngineId(null); - setActiveRunDir(null); - const msg = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to run workflow "${defName}": ${msg}`, "error"); - } - return true; - } - - // ── list [name] ── - if (sub === "list" || sub.startsWith("list ")) { - const filterName = sub.slice("list".length).trim() || undefined; - const base = projectRoot(); - const runs = listRuns(base, filterName); - if (runs.length === 0) { - ctx.ui.notify("No workflow runs found.", "info"); - return true; - } - const lines = runs.map((r) => { - const stepInfo = `${r.steps.completed}/${r.steps.total} steps`; - return `• ${r.name} [${r.timestamp}] — ${r.status} (${stepInfo})`; - }); - ctx.ui.notify(lines.join("\n"), "info"); - return true; - } - - // ── validate <name> ── - if (sub === "validate" || sub.startsWith("validate ")) { - const defName = sub.slice("validate".length).trim(); - if (!defName) { - ctx.ui.notify("Usage: /gsd workflow validate <name>", "warning"); - return true; - } - const base = projectRoot(); - const defPath = join(base, ".gsd", "workflow-defs", `${defName}.yaml`); - if (!existsSync(defPath)) { - ctx.ui.notify(`Definition not found: ${defPath}`, "error"); - return true; - } - try { - const raw = readFileSync(defPath, "utf-8"); - const parsed = parseYaml(raw); - const result = validateDefinition(parsed); - if (result.valid) { - ctx.ui.notify(`✓ "${defName}" is a valid workflow definition.`, "info"); - } else { - ctx.ui.notify(`✗ "${defName}" has errors:\n - ${result.errors.join("\n - ")}`, "error"); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to validate "${defName}": ${msg}`, "error"); - } - return true; - } - - // ── pause ── - if (sub === "pause") { - const engineId = getActiveEngineId(); - if (engineId === "dev" || engineId === null) { - ctx.ui.notify("No custom workflow is running. Use /gsd pause for dev workflow.", "warning"); - return true; - } - if (!isAutoActive()) { - ctx.ui.notify("Auto-mode is not active.", "warning"); - return true; - } - await pauseAuto(ctx, pi); - ctx.ui.notify("Custom workflow paused.", "info"); - return true; - } - - // ── resume ── - if (sub === "resume") { - const engineId = getActiveEngineId(); - if (engineId === "dev" || engineId === null) { - ctx.ui.notify("No custom workflow to resume. Use /gsd auto for dev workflow.", "warning"); - return true; - } - startAutoDetached(ctx, pi, projectRoot(), false); - ctx.ui.notify("Custom workflow resumed.", "info"); - return true; - } - - // Unknown subcommand — show usage - ctx.ui.notify(`Unknown workflow subcommand: "${sub}"\n\n${WORKFLOW_USAGE}`, "warning"); - return true; -} - -export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { - // ── /gsd do — natural language routing (must be early to route to other commands) ── - if (trimmed === "do" || trimmed.startsWith("do ")) { - const { handleDo } = await import("../../commands-do.js"); - await handleDo(trimmed.replace(/^do\s*/, "").trim(), ctx, pi); - return true; - } - // ── Backlog management ── - if (trimmed === "backlog" || trimmed.startsWith("backlog ")) { - const { handleBacklog } = await import("../../commands-backlog.js"); - await handleBacklog(trimmed.replace(/^backlog\s*/, "").trim(), ctx, pi); - return true; - } - // ── Custom workflow commands (`/gsd workflow ...`) ── - if (trimmed === "workflow" || trimmed.startsWith("workflow ")) { - const sub = trimmed.slice("workflow".length).trim(); - return handleCustomWorkflow(sub, ctx, pi); - } - - if (trimmed === "queue") { - await showQueue(ctx, pi, projectRoot()); - return true; - } - if (trimmed === "discuss") { - await showDiscuss(ctx, pi, projectRoot()); - return true; - } - if (trimmed === "quick" || trimmed.startsWith("quick ")) { - if (isAutoActive()) { - ctx.ui.notify( - "/gsd quick cannot run while auto-mode is active.\n" + - "Stop auto-mode first with /gsd stop, then run /gsd quick.", - "error", - ); - return true; - } - await handleQuick(trimmed.replace(/^quick\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "new-milestone") { - const basePath = projectRoot(); - const headlessContextPath = join(gsdRoot(basePath), "runtime", "headless-context.md"); - if (existsSync(headlessContextPath)) { - const seedContext = readFileSync(headlessContextPath, "utf-8"); - try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ } - await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext); - } else { - const { showWorkflowEntry } = await import("../../guided-flow.js"); - await showWorkflowEntry(ctx, pi, basePath); - } - return true; - } - if (trimmed === "start" || trimmed.startsWith("start ")) { - await handleStart(trimmed.replace(/^start\s*/, "").trim(), ctx, pi); - return true; - } - if (trimmed === "templates" || trimmed.startsWith("templates ")) { - await handleTemplates(trimmed.replace(/^templates\s*/, "").trim(), ctx); - return true; - } - if (trimmed === "park" || trimmed.startsWith("park ")) { - const basePath = projectRoot(); - const arg = trimmed.replace(/^park\s*/, "").trim(); - let targetId = arg; - if (!targetId) { - const state = await deriveState(basePath); - if (!state.activeMilestone) { - ctx.ui.notify("No active milestone to park.", "warning"); - return true; - } - targetId = state.activeMilestone.id; - } - if (isParked(basePath, targetId)) { - ctx.ui.notify(`${targetId} is already parked. Use /gsd unpark ${targetId} to reactivate.`, "info"); - return true; - } - const reasonParts = arg.replace(targetId, "").trim().replace(/^["']|["']$/g, ""); - const reason = reasonParts || "Parked via /gsd park"; - const success = parkMilestone(basePath, targetId, reason); - ctx.ui.notify( - success ? `Parked ${targetId}. Run /gsd unpark ${targetId} to reactivate.` : `Could not park ${targetId} — milestone not found.`, - success ? "info" : "warning", - ); - return true; - } - if (trimmed === "unpark" || trimmed.startsWith("unpark ")) { - const basePath = projectRoot(); - const arg = trimmed.replace(/^unpark\s*/, "").trim(); - let targetId = arg; - if (!targetId) { - const state = await deriveState(basePath); - const parkedEntries = state.registry.filter((entry) => entry.status === "parked"); - if (parkedEntries.length === 0) { - ctx.ui.notify("No parked milestones.", "info"); - return true; - } - if (parkedEntries.length === 1) { - targetId = parkedEntries[0].id; - } else { - ctx.ui.notify(`Parked milestones: ${parkedEntries.map((entry) => entry.id).join(", ")}. Specify which to unpark: /gsd unpark <id>`, "info"); - return true; - } - } - const success = unparkMilestone(basePath, targetId); - ctx.ui.notify( - success ? `Unparked ${targetId}. It will resume its normal position in the queue.` : `Could not unpark ${targetId} — milestone not found or not parked.`, - success ? "info" : "warning", - ); - return true; - } - return false; -} - -export function getNextMilestoneId(basePath: string): string { - const milestoneIds = findMilestoneIds(basePath); - const uniqueIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - return nextMilestoneId(milestoneIds, uniqueIds); -} diff --git a/src/resources/extensions/gsd/commands/index.ts b/src/resources/extensions/gsd/commands/index.ts deleted file mode 100644 index af26e11bd..000000000 --- a/src/resources/extensions/gsd/commands/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -import { SF_COMMAND_DESCRIPTION, getGsdArgumentCompletions } from "./catalog.js"; - -export function registerGSDCommand(pi: ExtensionAPI): void { - pi.registerCommand("gsd", { - description: SF_COMMAND_DESCRIPTION, - getArgumentCompletions: getGsdArgumentCompletions, - handler: async (args: string, ctx: ExtensionCommandContext) => { - const { handleGSDCommand } = await import("./dispatcher.js"); - const { setStderrLoggingEnabled } = await import("../workflow-logger.js"); - const previousStderrSetting = setStderrLoggingEnabled(false); - try { - await handleGSDCommand(args, ctx, pi); - } finally { - setStderrLoggingEnabled(previousStderrSetting); - } - }, - }); -} diff --git a/src/resources/extensions/gsd/complexity-classifier.ts b/src/resources/extensions/gsd/complexity-classifier.ts deleted file mode 100644 index ea085b1d7..000000000 --- a/src/resources/extensions/gsd/complexity-classifier.ts +++ /dev/null @@ -1,329 +0,0 @@ -// SF Extension — Complexity Classifier -// Classifies unit complexity for dynamic model routing. -// Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification. - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { getAdaptiveTierAdjustment } from "./routing-history.js"; -import { parseUnitId } from "./unit-id.js"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export type ComplexityTier = "light" | "standard" | "heavy"; - -export interface ClassificationResult { - tier: ComplexityTier; - reason: string; - downgraded: boolean; // true if budget pressure lowered the tier - taskMetadata?: TaskMetadata; -} - -export interface TaskMetadata { - fileCount?: number; - dependencyCount?: number; - isNewFile?: boolean; - tags?: string[]; - estimatedLines?: number; - codeBlockCount?: number; // number of fenced code blocks in plan - complexityKeywords?: string[]; // detected complexity signals -} - -// ─── Unit Type → Default Tier Mapping ──────────────────────────────────────── - -const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = { - // Tier 1 — Light: structured summaries, completion, UAT - "complete-slice": "light", - "run-uat": "light", - - // Tier 2 — Standard: research, routine discussion - "discuss-milestone": "standard", - "discuss-slice": "standard", - "research-milestone": "standard", - "research-slice": "standard", - - // Tier 3 — Heavy: planning, execution, replanning (requires deep reasoning) - // Planning is heavy so it uses the best configured model (e.g. Opus) and is - // not downgraded by dynamic routing when a capable model is configured. - "plan-milestone": "heavy", - "plan-slice": "heavy", - "execute-task": "standard", // default standard, upgraded by metadata - "replan-slice": "heavy", - "reassess-roadmap": "heavy", -}; - -// ─── Public API ────────────────────────────────────────────────────────────── - -/** - * Classify unit complexity to determine which model tier to use. - * - * @param unitType The type of unit being dispatched - * @param unitId The unit ID (e.g. "M001/S01/T01") - * @param basePath Project base path (for reading task plans) - * @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget - * @param metadata Optional pre-parsed task metadata - */ -export function classifyUnitComplexity( - unitType: string, - unitId: string, - basePath: string, - budgetPct?: number, - metadata?: TaskMetadata, -): ClassificationResult { - // Hook units default to light - if (unitType.startsWith("hook/")) { - const result: ClassificationResult = { tier: "light", reason: "hook unit", downgraded: false, taskMetadata: undefined }; - return applyBudgetPressure(result, budgetPct); - } - - // Start with the default tier for this unit type - let tier = UNIT_TYPE_TIERS[unitType] ?? "standard"; - let reason = `unit type: ${unitType}`; - let taskMeta: TaskMetadata | undefined; - - // For execute-task, analyze task metadata for complexity signals - if (unitType === "execute-task") { - // Extract metadata once and reuse throughout to avoid double-extraction - taskMeta = metadata ?? extractTaskMetadata(unitId, basePath); - const taskAnalysis = analyzeTaskComplexity(unitId, basePath, taskMeta); - tier = taskAnalysis.tier; - reason = taskAnalysis.reason; - } - - // For plan-slice, check if the slice has many tasks (complex planning) - if (unitType === "plan-slice" || unitType === "plan-milestone") { - const planAnalysis = analyzePlanComplexity(unitId, basePath); - if (planAnalysis) { - tier = planAnalysis.tier; - reason = planAnalysis.reason; - } - } - - // Adaptive learning: check if history suggests bumping the tier - // Use already-extracted taskMeta.tags if available to avoid double-extraction - const tags = taskMeta?.tags ?? metadata?.tags; - const adaptiveAdjustment = getAdaptiveTierAdjustment(unitType, tier, tags); - if (adaptiveAdjustment && tierOrdinal(adaptiveAdjustment) > tierOrdinal(tier)) { - reason = `${reason} (adaptive: high failure rate at ${tier})`; - tier = adaptiveAdjustment; - } - - const result: ClassificationResult = { tier, reason, downgraded: false, taskMetadata: taskMeta }; - return applyBudgetPressure(result, budgetPct); -} - -/** - * Get a short label for the tier (for dashboard display). - */ -export function tierLabel(tier: ComplexityTier): string { - switch (tier) { - case "light": return "L"; - case "standard": return "S"; - case "heavy": return "H"; - } -} - -/** - * Get the tier ordering value (for comparison). - */ -export function tierOrdinal(tier: ComplexityTier): number { - switch (tier) { - case "light": return 0; - case "standard": return 1; - case "heavy": return 2; - } -} - -// ─── Task Complexity Analysis ──────────────────────────────────────────────── - -interface TaskAnalysis { - tier: ComplexityTier; - reason: string; -} - -function analyzeTaskComplexity( - unitId: string, - basePath: string, - metadata?: TaskMetadata, -): TaskAnalysis { - // Try to read task plan for complexity signals - const meta = metadata ?? extractTaskMetadata(unitId, basePath); - - // Heavy signals - if (meta.dependencyCount && meta.dependencyCount >= 3) { - return { tier: "heavy", reason: `${meta.dependencyCount} dependencies` }; - } - if (meta.fileCount && meta.fileCount >= 6) { - return { tier: "heavy", reason: `${meta.fileCount} files to modify` }; - } - if (meta.estimatedLines && meta.estimatedLines >= 500) { - return { tier: "heavy", reason: `~${meta.estimatedLines} lines estimated` }; - } - - // Heavy signals from complexity keywords (Phase 4) - if (meta.complexityKeywords && meta.complexityKeywords.length >= 2) { - return { tier: "heavy", reason: `complex: ${meta.complexityKeywords.join(", ")}` }; - } - if (meta.codeBlockCount && meta.codeBlockCount >= 5) { - return { tier: "heavy", reason: `${meta.codeBlockCount} code blocks in plan` }; - } - - // Standard signals from single complexity keyword - if (meta.complexityKeywords && meta.complexityKeywords.length === 1) { - return { tier: "standard", reason: `${meta.complexityKeywords[0]} task` }; - } - - // Light signals (simple tasks) - if (meta.tags?.some(t => /^(docs?|readme|comment|config|typo|rename)$/i.test(t))) { - return { tier: "light", reason: `simple task: ${meta.tags.join(", ")}` }; - } - if (meta.fileCount !== undefined && meta.fileCount <= 1 && !meta.isNewFile) { - return { tier: "light", reason: "single file modification" }; - } - - // Standard by default - return { tier: "standard", reason: "standard execution task" }; -} - -function analyzePlanComplexity( - unitId: string, - basePath: string, -): TaskAnalysis | null { - // Check if this is a milestone-level plan (more complex) vs single slice - const { milestone: mid, slice: sid } = parseUnitId(unitId); - if (!sid) { - // Milestone-level planning is always heavy — requires full context and best model - return { tier: "heavy", reason: "milestone-level planning" }; - } - - // For slice planning, try to read the context/research to gauge complexity - // If research exists and is large, bump to heavy - const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md"); - try { - if (existsSync(researchPath)) { - const content = readFileSync(researchPath, "utf-8"); - const lineCount = content.split("\n").length; - if (lineCount > 200) { - return { tier: "heavy", reason: `complex slice: ${lineCount}-line research` }; - } - } - } catch { - // Non-fatal - } - - return null; // Use default tier -} - -/** - * Extract task metadata from the task plan file on disk. - */ -export function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata { - const meta: TaskMetadata = {}; - const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); - if (!mid || !sid || !tid) return meta; - const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`); - - try { - if (!existsSync(taskPlanPath)) return meta; - const content = readFileSync(taskPlanPath, "utf-8"); - const lines = content.split("\n"); - - // Count files mentioned in "Files:" or "- Files:" lines - const fileLines = lines.filter(l => /^\s*-?\s*files?\s*:/i.test(l)); - if (fileLines.length > 0) { - // Count comma-separated or bullet-pointed files - const allFiles = new Set<string>(); - for (const line of fileLines) { - const filesStr = line.replace(/^\s*-?\s*files?\s*:\s*/i, ""); - const files = filesStr.split(/[,;]/).map(f => f.trim()).filter(Boolean); - files.forEach(f => allFiles.add(f)); - } - meta.fileCount = allFiles.size; - } - - // Check for "new file" or "create" keywords - meta.isNewFile = lines.some(l => /\b(create|new file|scaffold|bootstrap)\b/i.test(l)); - - // Look for tags/labels in frontmatter or content - const tags: string[] = []; - if (content.match(/\b(refactor|migration|architect)/i)) tags.push("refactor"); - if (content.match(/\b(test|spec|coverage)\b/i)) tags.push("test"); - if (content.match(/\b(doc|readme|comment|jsdoc)\b/i)) tags.push("docs"); - if (content.match(/\b(config|env|setting)\b/i)) tags.push("config"); - if (content.match(/\b(rename|typo|spelling)\b/i)) tags.push("rename"); - meta.tags = tags; - - // Try to extract estimated lines from content - const estimateMatch = content.match(/~?\s*(\d+)\s*lines?\b/i); - if (estimateMatch) { - meta.estimatedLines = parseInt(estimateMatch[1], 10); - } - - // Phase 4: Deeper introspection signals - - // Count fenced code blocks (```) — more code blocks = more complex implementation - const codeBlockMatches = content.match(/^```/gm); - meta.codeBlockCount = codeBlockMatches ? Math.floor(codeBlockMatches.length / 2) : 0; - - // Detect complexity keywords that suggest harder tasks - const complexityKeywords: string[] = []; - if (content.match(/\b(migration|migrate|schema change)\b/i)) complexityKeywords.push("migration"); - if (content.match(/\b(architect|design pattern|system design)\b/i)) complexityKeywords.push("architecture"); - if (content.match(/\b(security|auth|encrypt|credential|vulnerability)\b/i)) complexityKeywords.push("security"); - if (content.match(/\b(performance|optimize|cache|index)\b/i)) complexityKeywords.push("performance"); - if (content.match(/\b(concurrent|parallel|race condition|mutex|lock)\b/i)) complexityKeywords.push("concurrency"); - if (content.match(/\b(backward.?compat|breaking change|deprecat)\b/i)) complexityKeywords.push("compatibility"); - meta.complexityKeywords = complexityKeywords; - } catch { - // Non-fatal — metadata extraction is best-effort - } - - return meta; -} - -// ─── Budget Pressure ───────────────────────────────────────────────────────── - -/** - * Apply budget pressure to a classification result. - * As budget usage increases, more aggressively downgrade tiers. - * - * - <50%: Normal classification (no change) - * - 50-75%: Tier 2 → Tier 1 where possible - * - 75-90%: Only heavy tasks keep configured model - * - >90%: Everything except replan-slice gets cheapest model - */ -function applyBudgetPressure( - result: ClassificationResult, - budgetPct?: number, -): ClassificationResult { - if (budgetPct === undefined || budgetPct < 0.5) return result; - - const original = result.tier; - - if (budgetPct >= 0.9) { - // >90%: almost everything goes to light - if (result.tier !== "heavy") { - result.tier = "light"; - } else { - // Even heavy gets downgraded to standard - result.tier = "standard"; - } - } else if (budgetPct >= 0.75) { - // 75-90%: only heavy stays, everything else goes to light - if (result.tier === "standard") { - result.tier = "light"; - } - } else { - // 50-75%: standard → light - if (result.tier === "standard") { - result.tier = "light"; - } - } - - if (result.tier !== original) { - result.downgraded = true; - result.reason = `${result.reason} (budget pressure: ${Math.round(budgetPct * 100)}%)`; - } - - return result; -} diff --git a/src/resources/extensions/gsd/config-overlay.ts b/src/resources/extensions/gsd/config-overlay.ts deleted file mode 100644 index 5d546797a..000000000 --- a/src/resources/extensions/gsd/config-overlay.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * SF Configuration Overlay - * - * Read-only TUI overlay showing the effective SF configuration: - * token profile, model assignments, dynamic routing, git settings, - * budget, workflow toggles, and preference file sources. - * Opened via `/gsd show-config` or `/gsd config`. - */ - -import type { Theme } from "@sf-run/pi-coding-agent"; -import { matchesKey, Key, truncateToWidth } from "@sf-run/pi-tui"; - -import { - loadEffectiveGSDPreferences, - loadGlobalGSDPreferences, - loadProjectGSDPreferences, - getGlobalGSDPreferencesPath, - getProjectGSDPreferencesPath, - resolveDynamicRoutingConfig, - resolveEffectiveProfile, - resolveModelWithFallbacksForUnit, - resolveAutoSupervisorConfig, -} from "./preferences.js"; - -// ─── Data Collection ────────────────────────────────────────────────────── - -interface ConfigSection { - title: string; - rows: Array<{ label: string; value: string; accent?: boolean }>; -} - -function collectConfigSections(): ConfigSection[] { - const sections: ConfigSection[] = []; - - const globalPrefs = loadGlobalGSDPreferences(); - const projectPrefs = loadProjectGSDPreferences(); - const effective = loadEffectiveGSDPreferences(); - const prefs = effective?.preferences; - - // ─── Sources ───────────────────────────────────────────────────────── - sections.push({ - title: "Sources", - rows: [ - { label: "Global", value: globalPrefs ? globalPrefs.path : `(none) ${getGlobalGSDPreferencesPath()}` }, - { label: "Project", value: projectPrefs ? projectPrefs.path : `(none) ${getProjectGSDPreferencesPath()}` }, - ], - }); - - // ─── Profile ───────────────────────────────────────────────────────── - const profile = resolveEffectiveProfile(); - const profileRows: ConfigSection["rows"] = [ - { label: "Token profile", value: `${profile}${!prefs?.token_profile ? " (default)" : ""}`, accent: true }, - ]; - if (prefs?.mode) profileRows.push({ label: "Workflow mode", value: prefs.mode }); - sections.push({ title: "Profile", rows: profileRows }); - - // ─── Models ────────────────────────────────────────────────────────── - const unitTypes: Array<[string, string]> = [ - ["research", "research-milestone"], - ["planning", "plan-milestone"], - ["discuss", "discuss-milestone"], - ["execution", "execute-task"], - ["completion", "complete-slice"], - ["validation", "run-uat"], - ]; - - const modelRows: ConfigSection["rows"] = []; - for (const [label, unitType] of unitTypes) { - const resolved = resolveModelWithFallbacksForUnit(unitType); - if (resolved) { - let val = resolved.primary; - if (resolved.fallbacks.length > 0) { - val += ` \u2192 ${resolved.fallbacks.join(" \u2192 ")}`; - } - modelRows.push({ label, value: val }); - } else { - modelRows.push({ label, value: "(inherit)" }); - } - } - - // subagent is a direct config key - const models = prefs?.models as Record<string, unknown> | undefined; - const subVal = models?.subagent; - if (subVal) { - const model = typeof subVal === "string" ? subVal : (subVal as { model?: string })?.model ?? "?"; - modelRows.push({ label: "subagent", value: model }); - } else { - modelRows.push({ label: "subagent", value: "(inherit)" }); - } - - sections.push({ title: "Models", rows: modelRows }); - - // ─── Dynamic Routing ───────────────────────────────────────────────── - const routing = resolveDynamicRoutingConfig(); - const routingRows: ConfigSection["rows"] = [ - { label: "Enabled", value: routing.enabled ? "yes" : "no", accent: routing.enabled }, - ]; - if (routing.enabled) { - routingRows.push({ label: "Escalate on fail", value: routing.escalate_on_failure !== false ? "yes" : "no" }); - routingRows.push({ label: "Budget pressure", value: routing.budget_pressure !== false ? "yes" : "no" }); - routingRows.push({ label: "Cross-provider", value: routing.cross_provider !== false ? "yes" : "no" }); - if (routing.tier_models) { - const tm = routing.tier_models; - if (tm.light) routingRows.push({ label: "[L] light", value: tm.light }); - if (tm.standard) routingRows.push({ label: "[S] standard", value: tm.standard }); - if (tm.heavy) routingRows.push({ label: "[H] heavy", value: tm.heavy }); - } - } - sections.push({ title: "Dynamic Routing", rows: routingRows }); - - // ─── Git ───────────────────────────────────────────────────────────── - if (prefs?.git) { - const g = prefs.git; - const gitRows: ConfigSection["rows"] = []; - if (g.isolation !== undefined) gitRows.push({ label: "Isolation", value: String(g.isolation) }); - if (g.auto_push !== undefined) gitRows.push({ label: "Auto push", value: String(g.auto_push) }); - if (g.push_branches !== undefined) gitRows.push({ label: "Push branches", value: String(g.push_branches) }); - if (g.merge_strategy) gitRows.push({ label: "Merge strategy", value: g.merge_strategy }); - if (g.main_branch) gitRows.push({ label: "Main branch", value: g.main_branch }); - if (g.remote) gitRows.push({ label: "Remote", value: g.remote }); - if (gitRows.length > 0) sections.push({ title: "Git", rows: gitRows }); - } - - // ─── Budget ────────────────────────────────────────────────────────── - if (prefs?.budget_ceiling !== undefined || prefs?.budget_enforcement) { - const budgetRows: ConfigSection["rows"] = []; - if (prefs.budget_ceiling !== undefined) budgetRows.push({ label: "Ceiling", value: `$${prefs.budget_ceiling}` }); - if (prefs.budget_enforcement) budgetRows.push({ label: "Enforcement", value: String(prefs.budget_enforcement) }); - sections.push({ title: "Budget", rows: budgetRows }); - } - - // ─── Auto Supervisor ───────────────────────────────────────────────── - if (prefs?.auto_supervisor) { - const sup = resolveAutoSupervisorConfig(); - const supRows: ConfigSection["rows"] = []; - if (sup.model) supRows.push({ label: "Model", value: sup.model }); - supRows.push({ label: "Soft timeout", value: `${sup.soft_timeout_minutes}m` }); - supRows.push({ label: "Idle timeout", value: `${sup.idle_timeout_minutes}m` }); - supRows.push({ label: "Hard timeout", value: `${sup.hard_timeout_minutes}m` }); - sections.push({ title: "Auto Supervisor", rows: supRows }); - } - - // ─── Toggles ───────────────────────────────────────────────────────── - const toggleRows: ConfigSection["rows"] = []; - if (prefs?.phases) { - const p = prefs.phases; - if (p.skip_research) toggleRows.push({ label: "skip_research", value: "on" }); - if (p.skip_reassess) toggleRows.push({ label: "skip_reassess", value: "on" }); - if (p.skip_slice_research) toggleRows.push({ label: "skip_slice_research", value: "on" }); - if (p.skip_milestone_validation) toggleRows.push({ label: "skip_milestone_validation", value: "on" }); - if (p.require_slice_discussion) toggleRows.push({ label: "require_slice_discussion", value: "on" }); - } - if (prefs?.uat_dispatch) toggleRows.push({ label: "uat_dispatch", value: "on" }); - if (prefs?.auto_visualize) toggleRows.push({ label: "auto_visualize", value: "on" }); - if (prefs?.auto_report === false) toggleRows.push({ label: "auto_report", value: "off" }); - if (prefs?.show_token_cost) toggleRows.push({ label: "show_token_cost", value: "on" }); - if (prefs?.forensics_dedup) toggleRows.push({ label: "forensics_dedup", value: "on" }); - if (prefs?.unique_milestone_ids) toggleRows.push({ label: "unique_milestone_ids", value: "on" }); - if (prefs?.service_tier) toggleRows.push({ label: "service_tier", value: prefs.service_tier }); - if (prefs?.search_provider && prefs.search_provider !== "auto") toggleRows.push({ label: "search_provider", value: prefs.search_provider }); - if (prefs?.context_selection) toggleRows.push({ label: "context_selection", value: prefs.context_selection }); - if (prefs?.widget_mode && prefs.widget_mode !== "full") toggleRows.push({ label: "widget_mode", value: prefs.widget_mode }); - if (prefs?.experimental?.rtk) toggleRows.push({ label: "experimental.rtk", value: "on" }); - if (toggleRows.length > 0) sections.push({ title: "Toggles", rows: toggleRows }); - - // ─── Parallel ──────────────────────────────────────────────────────── - if (prefs?.parallel) { - const pc = prefs.parallel; - const parallelRows: ConfigSection["rows"] = []; - if (pc.max_workers !== undefined) parallelRows.push({ label: "Max workers", value: String(pc.max_workers) }); - if (pc.merge_strategy) parallelRows.push({ label: "Merge strategy", value: pc.merge_strategy }); - if (pc.auto_merge) parallelRows.push({ label: "Auto merge", value: pc.auto_merge }); - if (parallelRows.length > 0) sections.push({ title: "Parallel", rows: parallelRows }); - } - - // ─── Hooks ─────────────────────────────────────────────────────────── - const postHooks = prefs?.post_unit_hooks?.filter(h => h.enabled !== false) ?? []; - const preHooks = prefs?.pre_dispatch_hooks?.filter(h => h.enabled !== false) ?? []; - if (postHooks.length > 0 || preHooks.length > 0) { - const hookRows: ConfigSection["rows"] = []; - if (preHooks.length > 0) hookRows.push({ label: "Pre-dispatch", value: `${preHooks.length} active` }); - if (postHooks.length > 0) hookRows.push({ label: "Post-unit", value: `${postHooks.length} active` }); - sections.push({ title: "Hooks", rows: hookRows }); - } - - // ─── Warnings ──────────────────────────────────────────────────────── - const warnings = [ - ...(globalPrefs?.warnings ?? []), - ...(projectPrefs?.warnings ?? []), - ]; - if (warnings.length > 0) { - sections.push({ - title: "Warnings", - rows: warnings.map(w => ({ label: "\u26a0", value: w })), - }); - } - - return sections; -} - -// ─── Plain Text Formatter (headless/RPC fallback) ───────────────────────── - -export function formatConfigText(): string { - const sections = collectConfigSections(); - const lines: string[] = ["SF Configuration\n"]; - - let maxLabel = 0; - for (const section of sections) { - for (const row of section.rows) { - if (row.label.length > maxLabel) maxLabel = row.label.length; - } - } - const pad = Math.min(maxLabel + 2, 24); - - for (const section of sections) { - lines.push(""); - lines.push(section.title.toUpperCase()); - for (const row of section.rows) { - lines.push(` ${row.label.padEnd(pad)}${row.value}`); - } - } - - return lines.join("\n"); -} - -// ─── Overlay Class ──────────────────────────────────────────────────────── - -export class GSDConfigOverlay { - private tui: { requestRender: () => void }; - private theme: Theme; - private onClose: () => void; - private sections: ConfigSection[]; - private cachedLines?: string[]; - private scrollOffset = 0; - private disposed = false; - - constructor( - tui: { requestRender: () => void }, - theme: Theme, - onClose: () => void, - ) { - this.tui = tui; - this.theme = theme; - this.onClose = onClose; - this.sections = collectConfigSections(); - } - - invalidate(): void { - this.cachedLines = undefined; - } - - dispose(): void { - this.disposed = true; - } - - handleInput(data: string): void { - if (matchesKey(data, Key.escape) || data === "q") { - this.dispose(); - this.onClose(); - return; - } - if (matchesKey(data, Key.down) || data === "j") { - this.scrollOffset++; - this.cachedLines = undefined; - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.up) || data === "k") { - this.scrollOffset = Math.max(0, this.scrollOffset - 1); - this.cachedLines = undefined; - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.pageDown)) { - this.scrollOffset += 10; - this.cachedLines = undefined; - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.pageUp)) { - this.scrollOffset = Math.max(0, this.scrollOffset - 10); - this.cachedLines = undefined; - this.tui.requestRender(); - return; - } - } - - render(width: number): string[] { - if (this.cachedLines) return this.cachedLines; - - const t = this.theme; - const w = Math.max(width, 50); - const allLines: string[] = []; - - // Header - allLines.push(t.bold(t.fg("accent", " SF Configuration "))); - allLines.push(t.fg("muted", "\u2500".repeat(w))); - - // Find max label width for alignment - let maxLabel = 0; - for (const section of this.sections) { - for (const row of section.rows) { - if (row.label.length > maxLabel) maxLabel = row.label.length; - } - } - const labelPad = Math.min(maxLabel + 2, 24); - - for (const section of this.sections) { - allLines.push(""); - allLines.push(t.bold(t.fg("accent", ` ${section.title}`))); - - for (const row of section.rows) { - const label = t.fg("muted", ` ${row.label.padEnd(labelPad)}`); - const value = row.accent ? t.bold(row.value) : row.value; - allLines.push(truncateToWidth(`${label}${value}`, w)); - } - } - - allLines.push(""); - allLines.push(t.fg("muted", ` ${"\u2500".repeat(w - 4)}`)); - allLines.push(t.fg("muted", " esc/q close \u2502 \u2191\u2193/jk scroll \u2502 /gsd prefs to edit")); - - // Apply scroll - const maxScroll = Math.max(0, allLines.length - 20); - this.scrollOffset = Math.min(this.scrollOffset, maxScroll); - const visible = allLines.slice(this.scrollOffset); - - this.cachedLines = visible; - return visible; - } -} diff --git a/src/resources/extensions/gsd/constants.ts b/src/resources/extensions/gsd/constants.ts deleted file mode 100644 index 052ae789d..000000000 --- a/src/resources/extensions/gsd/constants.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * SF Extension — Shared Constants - * - * Centralized timeout and cache-size constants used across the SF extension. - */ - -// ─── Timeouts ───────────────────────────────────────────────────────────────── - -/** Default timeout for verification-gate commands (ms). */ -export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; - -/** Default timeout for the dynamic bash tool (seconds). */ -export const DEFAULT_BASH_TIMEOUT_SECS = 120; - -// ─── Cache Sizes ────────────────────────────────────────────────────────────── - -/** Max directory-listing cache entries before eviction (#611). */ -export const DIR_CACHE_MAX = 200; - -/** Max parse-cache entries before eviction. */ -export const CACHE_MAX = 50; - -// ─── Tool Scoping ───────────────────────────────────────────────────────────── - -/** - * SF tools allowed during discuss flows (#2949). - * - * xAI/Grok (and potentially other providers with grammar-based constrained - * decoding) return "Grammar is too complex" (HTTP 400) when the combined - * tool schemas exceed their internal grammar limit. The full SF tool set - * registers ~33 tools with deeply nested schemas; discuss flows only need - * a small subset. - * - * By scoping tools to this allowlist during discuss dispatches, the grammar - * sent to the provider stays well under provider limits. - * - * Included tools and why: - * - gsd_summary_save: writes CONTEXT.md artifacts (all discuss prompts) - * - gsd_save_summary: alias for above - * - gsd_decision_save: records decisions (discuss.md output phase) - * - gsd_save_decision: alias for above - * - gsd_plan_milestone: writes roadmap (discuss.md single/multi milestone) - * - gsd_milestone_plan: alias for above - * - gsd_milestone_generate_id: generates milestone IDs (discuss.md multi-milestone) - * - gsd_generate_milestone_id: alias for above - * - gsd_requirement_update: updates requirements during discuss - * - gsd_update_requirement: alias for above - */ -export const DISCUSS_TOOLS_ALLOWLIST: readonly string[] = [ - // Context / summary writing - "gsd_summary_save", - "gsd_save_summary", - // Decision recording - "gsd_decision_save", - "gsd_save_decision", - // Milestone planning (needed for discuss.md output phase) - "gsd_plan_milestone", - "gsd_milestone_plan", - // Milestone ID generation (multi-milestone flow) - "gsd_milestone_generate_id", - "gsd_generate_milestone_id", - // Requirement updates - "gsd_requirement_update", - "gsd_update_requirement", -]; diff --git a/src/resources/extensions/gsd/context-budget.ts b/src/resources/extensions/gsd/context-budget.ts deleted file mode 100644 index 1788670a0..000000000 --- a/src/resources/extensions/gsd/context-budget.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Context budget engine — proportional allocation, section-boundary truncation, - * and executor context window resolution. - * - * All functions are pure or near-pure (dependency-injected). No global state, no I/O. - * Budget ratios are module-level constants for easy tuning. - * - * @see D001 (module location), D002 (200K fallback), D003 (section-boundary truncation) - */ - -import { type TokenProvider, getCharsPerToken } from "./token-counter.js"; - -// ─── Budget ratio constants ────────────────────────────────────────────────── -// Percentages of total context window allocated to each budget category. -// These are applied after tokens→chars conversion. - -/** Proportion of context window for dependency/prior-task summaries */ -const SUMMARY_RATIO = 0.15; - -/** Proportion of context window for inline context (plans, decisions, code) */ -const INLINE_CONTEXT_RATIO = 0.40; - -/** Proportion of context window for verification sections in prompts */ -const VERIFICATION_RATIO = 0.10; - -/** Approximate chars-per-token conversion factor */ -const CHARS_PER_TOKEN = 4; - -/** Default context window when none can be resolved (D002) */ -const DEFAULT_CONTEXT_WINDOW = 200_000; - -/** Percentage of context consumed before suggesting a continue-here checkpoint */ -const CONTINUE_THRESHOLD_PERCENT = 70; - -// ─── Task count bounds ─────────────────────────────────────────────────────── -// Task count range scales with context window. Smaller windows get fewer tasks -// to avoid overloading the executor. - -const TASK_COUNT_MIN = 2; - -/** Task count ceiling tiers: [contextWindowThreshold, maxTasks] */ -const TASK_COUNT_TIERS: [number, number][] = [ - [500_000, 8], // 500K+ tokens → up to 8 tasks - [200_000, 6], // 200K+ tokens → up to 6 tasks - [128_000, 5], // 128K+ tokens → up to 5 tasks - [0, 3], // anything smaller → up to 3 tasks -]; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface TruncationResult { - /** The (possibly truncated) content string */ - content: string; - /** Number of sections dropped during truncation; 0 when content fits */ - droppedSections: number; -} - -export interface BudgetAllocation { - /** Character budget for dependency/prior-task summaries */ - summaryBudgetChars: number; - /** Character budget for inline context (plans, decisions, code snippets) */ - inlineContextBudgetChars: number; - /** Recommended task count range for the executor at this context window */ - taskCountRange: { min: number; max: number }; - /** Percentage of context consumed before suggesting a continue-here checkpoint */ - continueThresholdPercent: number; - /** Character budget for verification sections */ - verificationBudgetChars: number; -} - -// ─── Minimal interface slices for dependency injection ─────────────────────── -// These avoid coupling to full ModelRegistry/GSDPreferences types in tests. - -export interface MinimalModel { - id: string; - provider: string; - contextWindow: number; -} - -export interface MinimalModelRegistry { - getAll(): MinimalModel[]; -} - -export interface MinimalPreferences { - models?: { - execution?: string | { model: string; fallbacks?: string[] }; - }; -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -/** - * Compute proportional budget allocations from a context window size (in tokens). - * - * Returns deterministic output for any given input. Invalid inputs (≤ 0) - * silently default to 200K (D002). - */ -export function computeBudgets(contextWindow: number, provider?: TokenProvider): BudgetAllocation { - const effectiveWindow = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW; - const charsPerToken = provider ? getCharsPerToken(provider) : CHARS_PER_TOKEN; - const totalChars = effectiveWindow * charsPerToken; - - return { - summaryBudgetChars: Math.floor(totalChars * SUMMARY_RATIO), - inlineContextBudgetChars: Math.floor(totalChars * INLINE_CONTEXT_RATIO), - verificationBudgetChars: Math.floor(totalChars * VERIFICATION_RATIO), - continueThresholdPercent: CONTINUE_THRESHOLD_PERCENT, - taskCountRange: { - min: TASK_COUNT_MIN, - max: resolveTaskCountMax(effectiveWindow), - }, - }; -} - -/** - * Truncate content at markdown section boundaries to fit within a character budget. - * - * Splits on `### ` headings and `---` dividers. Keeps whole sections that fit. - * Appends `[...truncated N sections]` when content is dropped. - * Returns content unchanged when it fits within budget. - * - * @see D003 — section-boundary truncation is mandatory; mid-section cuts are unacceptable. - */ -export function truncateAtSectionBoundary(content: string, budgetChars: number): TruncationResult { - if (!content || content.length <= budgetChars) { - return { content, droppedSections: 0 }; - } - - // Split on section markers: ### headings or --- dividers (on their own line) - const sections = splitIntoSections(content); - - if (sections.length <= 1) { - // No section markers — keep as much as fits from the start - const truncated = content.slice(0, budgetChars); - return { content: truncated + "\n\n[...truncated 1 sections]", droppedSections: 1 }; - } - - // Greedily keep sections that fit - let usedChars = 0; - let keptCount = 0; - - for (const section of sections) { - const sectionLen = section.length; - if (usedChars + sectionLen > budgetChars && keptCount > 0) { - break; - } - // Always keep at least the first section (even if it exceeds budget) - usedChars += sectionLen; - keptCount++; - if (usedChars >= budgetChars) break; - } - - const droppedCount = sections.length - keptCount; - if (droppedCount === 0) { - return { content, droppedSections: 0 }; - } - - const kept = sections.slice(0, keptCount).join(""); - return { - content: kept.trimEnd() + `\n\n[...truncated ${droppedCount} sections]`, - droppedSections: droppedCount, - }; -} - -/** - * Resolve the executor model's context window size using a fallback chain: - * - * 1. Look up the configured executor model ID in preferences → find in registry → return contextWindow - * 2. Fall back to sessionContextWindow if provided - * 3. Fall back to 200K default (D002) - * - * Supports "provider/model" format in preferences for explicit provider targeting. - */ -export function resolveExecutorContextWindow( - registry: MinimalModelRegistry | undefined, - preferences: MinimalPreferences | undefined, - sessionContextWindow?: number, -): number { - // Step 1: Try configured executor model - if (preferences?.models?.execution && registry) { - const executionConfig = preferences.models.execution; - const modelId = typeof executionConfig === "string" - ? executionConfig - : executionConfig.model; - - if (modelId) { - const model = findModelById(registry, modelId); - if (model && model.contextWindow > 0) { - return model.contextWindow; - } - } - } - - // Step 2: Fall back to session context window - if (sessionContextWindow && sessionContextWindow > 0) { - return sessionContextWindow; - } - - // Step 3: Fall back to default (D002) - return DEFAULT_CONTEXT_WINDOW; -} - -/** - * Reduce content to fit within budget using section-boundary truncation. - */ -export function reduceToFit(content: string, budgetChars: number): TruncationResult { - if (!content || content.length <= budgetChars) { - return { content, droppedSections: 0 }; - } - return truncateAtSectionBoundary(content, budgetChars); -} - -// ─── Internal helpers ──────────────────────────────────────────────────────── - -/** - * Resolve task count ceiling from context window size. - * Larger windows support more tasks per slice. - */ -function resolveTaskCountMax(contextWindow: number): number { - for (const [threshold, max] of TASK_COUNT_TIERS) { - if (contextWindow >= threshold) return max; - } - return 3; // fallback — unreachable given tiers include 0 -} - -/** - * Split content into sections at `### ` headings or `---` dividers. - * Each section includes its leading marker. - */ -function splitIntoSections(content: string): string[] { - // Match section boundaries: ### heading or --- divider at start of line - const pattern = /^(?=### |\-{3,}\s*$)/m; - const parts = content.split(pattern).filter(p => p.length > 0); - return parts; -} - -/** - * Find a model in the registry by ID string. - * Supports "provider/model" format for explicit provider targeting, - * or bare model ID (first match wins). - */ -function findModelById(registry: MinimalModelRegistry, modelId: string): MinimalModel | undefined { - const allModels = registry.getAll(); - const slashIdx = modelId.indexOf("/"); - - if (slashIdx !== -1) { - const provider = modelId.substring(0, slashIdx).toLowerCase(); - const id = modelId.substring(slashIdx + 1).toLowerCase(); - return allModels.find( - m => m.provider.toLowerCase() === provider && m.id.toLowerCase() === id, - ); - } - - // Bare ID — first match - return allModels.find(m => m.id === modelId); -} diff --git a/src/resources/extensions/gsd/context-injector.ts b/src/resources/extensions/gsd/context-injector.ts deleted file mode 100644 index c5b90b752..000000000 --- a/src/resources/extensions/gsd/context-injector.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * context-injector.ts — Inject prior step artifacts as context into step prompts. - * - * Reads the frozen DEFINITION.yaml from a run directory, finds the current step's - * `contextFrom` references, locates each referenced step's `produces` artifacts - * on disk, reads their content (truncated to 10k chars), and prepends formatted - * context blocks to the step prompt. - * - * Observability: - * - Truncation is logged via console.warn when it occurs, preventing silent overflow. - * - Missing artifact files are skipped silently (the step may not have produced them yet). - * - Unknown step IDs in contextFrom produce a console.warn for diagnosis. - * - The frozen DEFINITION.yaml on disk is the single source of truth for contextFrom config. - */ - -import { readFileSync, existsSync } from "node:fs"; -import { join, resolve, sep } from "node:path"; -import type { StepDefinition } from "./definition-loader.js"; -import { readFrozenDefinition } from "./definition-io.js"; - -/** Maximum characters per artifact to prevent context window blowout. */ -const MAX_CONTEXT_CHARS = 10_000; - -/** - * Inject context from prior step artifacts into a step's prompt. - * - * Reads the frozen DEFINITION.yaml from `runDir`, finds the step matching - * `stepId`, and for each step ID in its `contextFrom` array, looks up that - * step's `produces` paths, reads them from disk (relative to `runDir`), - * truncates to MAX_CONTEXT_CHARS, and prepends as labeled context blocks. - * - * @param runDir — absolute path to the workflow run directory - * @param stepId — the step ID whose prompt to enrich - * @param prompt — the original step prompt - * @returns The prompt with context blocks prepended, or unchanged if no context applies - * @throws Error if DEFINITION.yaml is missing or unreadable - */ -export function injectContext( - runDir: string, - stepId: string, - prompt: string, -): string { - const def = readFrozenDefinition(runDir); - - const step = def.steps.find((s: StepDefinition) => s.id === stepId); - if (!step || !step.contextFrom || step.contextFrom.length === 0) { - return prompt; - } - - const contextBlocks: string[] = []; - - for (const refStepId of step.contextFrom) { - const refStep = def.steps.find((s: StepDefinition) => s.id === refStepId); - if (!refStep) { - console.warn( - `context-injector: step "${stepId}" references unknown step "${refStepId}" in contextFrom — skipping`, - ); - continue; - } - - if (!refStep.produces || refStep.produces.length === 0) { - continue; - } - - for (const relPath of refStep.produces) { - const absPath = resolve(runDir, relPath); - // Path traversal guard: ensure resolved path stays within runDir - if (!absPath.startsWith(resolve(runDir) + sep) && absPath !== resolve(runDir)) { - console.warn( - `context-injector: artifact path "${relPath}" resolves outside runDir — skipping`, - ); - continue; - } - if (!existsSync(absPath)) { - // Artifact not yet produced or optional — skip silently - continue; - } - - let content = readFileSync(absPath, "utf-8"); - - if (content.length > MAX_CONTEXT_CHARS) { - console.warn( - `context-injector: truncating artifact "${relPath}" from step "${refStepId}" ` + - `(${content.length} chars → ${MAX_CONTEXT_CHARS} chars)`, - ); - content = content.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]"; - } - - contextBlocks.push( - `--- Context from step "${refStepId}" (file: ${relPath}) ---\n${content}\n---`, - ); - } - } - - if (contextBlocks.length === 0) { - return prompt; - } - - return contextBlocks.join("\n\n") + "\n\n" + prompt; -} diff --git a/src/resources/extensions/gsd/context-masker.ts b/src/resources/extensions/gsd/context-masker.ts deleted file mode 100644 index 13eddd50f..000000000 --- a/src/resources/extensions/gsd/context-masker.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Observation masking for SF auto-mode sessions. - * - * Replaces tool result content older than N turns with a placeholder. - * Reduces context bloat between compactions with zero LLM overhead. - * Preserves message ordering, roles, and all assistant/user messages. - * - * Operates on the pi-ai Message[] format (post-convertToLlm, pre-provider): - * - toolResult messages: { role: "toolResult", content: TextContent[] } - * - bash results are already converted to: { role: "user", content: [{type:"text",text:"..."}] } - * and start with "Ran `" from bashExecutionToText. - */ - -interface MaskableMessage { - role: string; - content: unknown; - type?: string; - [key: string]: unknown; -} - -const MASK_PLACEHOLDER = "[result masked — within summarized history]"; -const MASK_CONTENT_BLOCK = [{ type: "text" as const, text: MASK_PLACEHOLDER }]; - -function findTurnBoundary(messages: MaskableMessage[], keepRecentTurns: number): number { - let turnsSeen = 0; - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - // In the LLM payload, genuine user turns have role "user". - // Tool results have role "toolResult" and are excluded by this check. - if (m.role === "user") { - // Skip bash-result user messages (converted from bashExecution) — these aren't real user turns - if (isBashResultUserMessage(m)) continue; - turnsSeen++; - if (turnsSeen >= keepRecentTurns) return i; - } - } - return 0; -} - -/** - * Detect user messages that originated from bashExecution. - * After convertToLlm, these are {role: "user", content: [{type:"text", text:"Ran `cmd`\n..."}]}. - * The bashExecutionToText format always starts with "Ran `". - */ -function isBashResultUserMessage(m: MaskableMessage): boolean { - if (m.role !== "user" || !Array.isArray(m.content)) return false; - const first = m.content[0]; - return first && typeof first === "object" && "text" in first && - typeof first.text === "string" && first.text.startsWith("Ran `"); -} - -function isMaskableMessage(m: MaskableMessage): boolean { - // Tool result messages (role: "toolResult" in pi-ai format) - if (m.role === "toolResult") return true; - // Bash-result user messages (converted from bashExecution by convertToLlm) - if (isBashResultUserMessage(m)) return true; - return false; -} - -export function createObservationMask(keepRecentTurns: number = 8) { - return (messages: MaskableMessage[]): MaskableMessage[] => { - const boundary = findTurnBoundary(messages, keepRecentTurns); - if (boundary === 0) return messages; - - return messages.map((m, i) => { - if (i >= boundary) return m; - if (isMaskableMessage(m)) { - // Content may be string or array of content blocks — always replace with array - return { ...m, content: MASK_CONTENT_BLOCK }; - } - return m; - }); - }; -} diff --git a/src/resources/extensions/gsd/context-store.ts b/src/resources/extensions/gsd/context-store.ts deleted file mode 100644 index e0db59594..000000000 --- a/src/resources/extensions/gsd/context-store.ts +++ /dev/null @@ -1,361 +0,0 @@ -// SF Context Store — Query Layer & Formatters -// -// Typed query functions for decisions and requirements from the DB views, -// with optional filtering. Format functions produce prompt-injectable markdown. -// All functions degrade gracefully: return empty results when DB unavailable, never throw. - -import { isDbAvailable, _getAdapter } from './gsd-db.js'; -import type { Decision, Requirement } from './types.js'; - -// ─── Query Functions ─────────────────────────────────────────────────────── - -export interface DecisionQueryOpts { - milestoneId?: string; - scope?: string; -} - -export interface RequirementQueryOpts { - milestoneId?: string; - sliceId?: string; - status?: string; -} - -/** - * Query active (non-superseded) decisions with optional filters. - * - milestoneId: filters where when_context LIKE '%milestoneId%' - * - scope: filters where scope = :scope (exact match) - * - * Returns [] if DB is not available. Never throws. - */ -export function queryDecisions(opts?: DecisionQueryOpts): Decision[] { - if (!isDbAvailable()) return []; - const adapter = _getAdapter(); - if (!adapter) return []; - - try { - const clauses: string[] = ['superseded_by IS NULL']; - const params: Record<string, unknown> = {}; - - if (opts?.milestoneId) { - clauses.push('when_context LIKE :milestone_pattern'); - params[':milestone_pattern'] = `%${opts.milestoneId}%`; - } - - if (opts?.scope) { - clauses.push('scope = :scope'); - params[':scope'] = opts.scope; - } - - const sql = `SELECT * FROM decisions WHERE ${clauses.join(' AND ')} ORDER BY seq`; - const rows = adapter.prepare(sql).all(params); - - return rows.map(row => ({ - seq: row['seq'] as number, - id: row['id'] as string, - when_context: row['when_context'] as string, - scope: row['scope'] as string, - decision: row['decision'] as string, - choice: row['choice'] as string, - rationale: row['rationale'] as string, - revisable: row['revisable'] as string, - made_by: (row['made_by'] as string as import('./types.js').DecisionMadeBy) ?? 'agent', - superseded_by: null, - })); - } catch { - return []; - } -} - -/** - * Query active (non-superseded) requirements with optional filters. - * - milestoneId: combined with sliceId for precise filtering (e.g. %M005/S01%) - * - sliceId: filters where primary_owner LIKE '%pattern%' OR supporting_slices LIKE '%pattern%' - * - status: filters where status = :status (exact match) - * - * Returns [] if DB is not available. Never throws. - */ -export function queryRequirements(opts?: RequirementQueryOpts): Requirement[] { - if (!isDbAvailable()) return []; - const adapter = _getAdapter(); - if (!adapter) return []; - - try { - const clauses: string[] = ['superseded_by IS NULL']; - const params: Record<string, unknown> = {}; - - // Combined milestone+slice filtering for precise scoping - if (opts?.milestoneId && opts?.sliceId) { - // Use combined pattern like %M005/S01% to avoid cross-milestone contamination - clauses.push('(primary_owner LIKE :combined_pattern OR supporting_slices LIKE :combined_pattern)'); - params[':combined_pattern'] = `%${opts.milestoneId}/${opts.sliceId}%`; - } else if (opts?.sliceId) { - // Slice-only filtering (legacy behavior) - clauses.push('(primary_owner LIKE :slice_pattern OR supporting_slices LIKE :slice_pattern)'); - params[':slice_pattern'] = `%${opts.sliceId}%`; - } else if (opts?.milestoneId) { - // Milestone-only filtering - clauses.push('(primary_owner LIKE :milestone_pattern OR supporting_slices LIKE :milestone_pattern)'); - params[':milestone_pattern'] = `%${opts.milestoneId}%`; - } - - if (opts?.status) { - clauses.push('status = :status'); - params[':status'] = opts.status; - } - - const sql = `SELECT * FROM requirements WHERE ${clauses.join(' AND ')} ORDER BY id`; - const rows = adapter.prepare(sql).all(params); - - return rows.map(row => ({ - id: row['id'] as string, - class: row['class'] as string, - status: row['status'] as string, - description: row['description'] as string, - why: row['why'] as string, - source: row['source'] as string, - primary_owner: row['primary_owner'] as string, - supporting_slices: row['supporting_slices'] as string, - validation: row['validation'] as string, - notes: row['notes'] as string, - full_content: row['full_content'] as string, - superseded_by: null, - })); - } catch { - return []; - } -} - -// ─── Format Functions ────────────────────────────────────────────────────── - -/** - * Format decisions as a markdown table matching DECISIONS.md format. - * Returns empty string for empty input. - */ -export function formatDecisionsForPrompt(decisions: Decision[]): string { - if (decisions.length === 0) return ''; - - const header = '| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'; - const separator = '|---|------|-------|----------|--------|-----------|------------|---------|'; - const rows = decisions.map(d => - `| ${d.id} | ${d.when_context} | ${d.scope} | ${d.decision} | ${d.choice} | ${d.rationale} | ${d.revisable} | ${d.made_by ?? 'agent'} |`, - ); - - return [header, separator, ...rows].join('\n'); -} - -/** - * Format requirements as structured H3 sections matching REQUIREMENTS.md format. - * Returns empty string for empty input. - */ -export function formatRequirementsForPrompt(requirements: Requirement[]): string { - if (requirements.length === 0) return ''; - - return requirements.map(r => { - const lines: string[] = [ - `### ${r.id}: ${r.description}`, - '', - `- **Class:** ${r.class}`, - `- **Status:** ${r.status}`, - `- **Why:** ${r.why}`, - `- **Source:** ${r.source}`, - `- **Primary Owner:** ${r.primary_owner}`, - ]; - - if (r.supporting_slices) { - lines.push(`- **Supporting Slices:** ${r.supporting_slices}`); - } - - lines.push(`- **Validation:** ${r.validation}`); - - if (r.notes) { - lines.push(`- **Notes:** ${r.notes}`); - } - - return lines.join('\n'); - }).join('\n\n'); -} - -// ─── Artifact Query Functions ────────────────────────────────────────────── - -/** - * Query a hierarchy artifact by its relative path. - * Returns the full_content string or null if not found/unavailable. - * Never throws. - */ -export function queryArtifact(path: string): string | null { - if (!isDbAvailable()) return null; - const adapter = _getAdapter(); - if (!adapter) return null; - - try { - const row = adapter.prepare('SELECT full_content FROM artifacts WHERE path = :path').get({ ':path': path }); - if (!row) return null; - const content = row['full_content'] as string; - return content || null; - } catch { - return null; - } -} - -/** - * Query PROJECT.md content from the artifacts table. - * PROJECT.md is stored with the relative path 'PROJECT.md' by the importer. - * Returns the content string or null if not found/unavailable. - * Never throws. - */ -export function queryProject(): string | null { - return queryArtifact('PROJECT.md'); -} - -// ─── Knowledge Query ─────────────────────────────────────────────────────── - -/** - * Filter KNOWLEDGE.md sections by keyword matching. - * Uses H2 sections, matches keywords case-insensitively against: - * 1. Section header text - * 2. First paragraph of section content (up to first blank line or next heading) - * - * Per D020, returns empty string (not null) when no matches found. - * This signals "no relevant knowledge" vs "file not found". - * - * @param content - Full KNOWLEDGE.md content - * @param keywords - Keywords to match (case-insensitive) - * @returns Concatenated matching sections with H2 headers, or empty string - */ -export async function queryKnowledge(content: string, keywords: string[]): Promise<string> { - if (!content || keywords.length === 0) return ''; - - // Lazy import to avoid circular dependency - const { extractAllSections } = await import('./files.js'); - - const sections = extractAllSections(content, 2); - if (sections.size === 0) return ''; - - // Normalize keywords for case-insensitive matching - const normalizedKeywords = keywords.map(k => k.toLowerCase()); - - const matchingSections: string[] = []; - - for (const [header, body] of sections) { - // Extract first paragraph: everything up to first blank line or next heading - const firstParagraph = body.split(/\n\s*\n|\n#/)[0] || ''; - - // Check if any keyword matches header or first paragraph - const headerLower = header.toLowerCase(); - const paragraphLower = firstParagraph.toLowerCase(); - - const matches = normalizedKeywords.some(kw => - headerLower.includes(kw) || paragraphLower.includes(kw) - ); - - if (matches) { - matchingSections.push(`## ${header}\n\n${body}`); - } - } - - return matchingSections.join('\n\n'); -} - -// ─── Roadmap Excerpt Formatter ───────────────────────────────────────────── - -/** - * Format a minimal roadmap excerpt for prompt injection. - * Parses the slice table from roadmap content, extracts: - * 1. Header row + separator - * 2. Predecessor row (if sliceId depends on one via the Depends column) - * 3. Target slice row - * 4. Reference directive pointing to full roadmap path - * - * Per D021, this minimizes injected content while preserving dependency awareness. - * Returns empty string if sliceId is not found in the table. - * Never throws. - * - * @param roadmapContent - Full content of the M###-ROADMAP.md file - * @param sliceId - Target slice ID (e.g. 'S02') - * @param roadmapPath - Optional path for reference directive (defaults to generic) - */ -export function formatRoadmapExcerpt( - roadmapContent: string, - sliceId: string, - roadmapPath = 'ROADMAP.md', -): string { - if (!roadmapContent || !sliceId) return ''; - - const lines = roadmapContent.split('\n'); - - // Find the slice table header: | ID | Slice | ... (case insensitive) - let headerIndex = -1; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line && /^\s*\|\s*ID\s*\|\s*Slice\s*\|/i.test(line)) { - headerIndex = i; - break; - } - } - - if (headerIndex === -1) return ''; - - // The separator should be the next line (|---|---|...) - const separatorIndex = headerIndex + 1; - if (separatorIndex >= lines.length) return ''; - - const headerLine = lines[headerIndex]; - const separatorLine = lines[separatorIndex]; - - // Validate separator line looks like |---|---|... (may include : for alignment) - if (!separatorLine || !/^\s*\|[\s:\-|]+\|/.test(separatorLine)) return ''; - - // Parse table rows after separator - interface SliceRow { - line: string; - id: string; - depends: string; - } - - const sliceRows: SliceRow[] = []; - for (let i = separatorIndex + 1; i < lines.length; i++) { - const line = lines[i]; - if (!line || !line.trim().startsWith('|')) break; // End of table - - // Parse row: | ID | Slice | Risk | Depends | Done | After this | - const cells = line.split('|').map(c => c.trim()); - // cells[0] is empty (before first |), cells[1] is ID, etc. - if (cells.length < 5) continue; - - const id = cells[1] || ''; - const depends = cells[4] || ''; // Depends column (0-indexed: empty, ID, Slice, Risk, Depends, ...) - - sliceRows.push({ line, id, depends }); - } - - // Find target slice row - const targetRow = sliceRows.find(r => r.id === sliceId); - if (!targetRow) return ''; - - // Find predecessor if target depends on one - // Depends column may contain: '—', 'S01', 'S01, S02', etc. - let predecessorRow: SliceRow | undefined; - const dependsRaw = targetRow.depends; - if (dependsRaw && dependsRaw !== '—' && dependsRaw !== '-') { - // Extract first dependency (e.g. 'S01' from 'S01, S02') - const depMatch = dependsRaw.match(/S\d+/); - if (depMatch) { - predecessorRow = sliceRows.find(r => r.id === depMatch[0]); - } - } - - // Build excerpt - const excerptLines: string[] = [headerLine!, separatorLine!]; - - if (predecessorRow) { - excerptLines.push(predecessorRow.line); - } - - excerptLines.push(targetRow.line); - - // Add reference directive - excerptLines.push(''); - excerptLines.push(`> See full roadmap: ${roadmapPath}`); - - return excerptLines.join('\n'); -} diff --git a/src/resources/extensions/gsd/crash-recovery.ts b/src/resources/extensions/gsd/crash-recovery.ts deleted file mode 100644 index 8fb4c1137..000000000 --- a/src/resources/extensions/gsd/crash-recovery.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * SF Crash Recovery - * - * Detects interrupted auto-mode sessions via a lock file. - * Written on auto-start, updated on each unit dispatch, deleted on clean stop. - * If the lock file exists on next startup, the previous session crashed. - * - * The lock records the pi session file path so crash recovery can read the - * surviving JSONL (pi appends entries incrementally via appendFileSync, - * so the file on disk reflects every tool call up to the crash point). - */ - -import { readFileSync, unlinkSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { atomicWriteSync } from "./atomic-write.js"; -import { effectiveLockFile } from "./session-lock.js"; -import { emitJournalEvent, queryJournal } from "./journal.js"; - -export interface LockData { - pid: number; - startedAt: string; - unitType: string; - unitId: string; - unitStartedAt: string; - /** Path to the pi session JSONL file that was active when this unit started. */ - sessionFile?: string; -} - -function lockPath(basePath: string): string { - return join(gsdRoot(basePath), effectiveLockFile()); -} - -/** Write or update the lock file with current auto-mode state. */ -export function writeLock( - basePath: string, - unitType: string, - unitId: string, - sessionFile?: string, -): void { - try { - const data: LockData = { - pid: process.pid, - startedAt: new Date().toISOString(), - unitType, - unitId, - unitStartedAt: new Date().toISOString(), - sessionFile, - }; - const lp = lockPath(basePath); - atomicWriteSync(lp, JSON.stringify(data, null, 2)); - } catch (e) { /* non-fatal: lock write failure */ void e; } -} - -/** Remove the lock file on clean stop. */ -export function clearLock(basePath: string): void { - try { - const p = lockPath(basePath); - if (existsSync(p)) unlinkSync(p); - } catch (e) { /* non-fatal: lock clear failure */ void e; } -} - -/** Check if a crash lock exists and return its data. */ -export function readCrashLock(basePath: string): LockData | null { - try { - const p = lockPath(basePath); - if (!existsSync(p)) return null; - const raw = readFileSync(p, "utf-8"); - return JSON.parse(raw) as LockData; - } catch (e) { - /* non-fatal: corrupt or unreadable lock file */ void e; - return null; - } -} - -/** - * Check whether the process that wrote the lock is still running. - * Uses `process.kill(pid, 0)` which sends no signal but checks liveness. - * Returns true if the PID matches our own — we are the lock holder (#2470). - */ -export function isLockProcessAlive(lock: LockData): boolean { - const pid = lock.pid; - if (!Number.isInteger(pid) || pid <= 0) return false; - // Our own PID means WE hold this lock — we are alive. (#2470) - // Callers that need to distinguish "our lock" from "someone else's lock" - // (e.g. startAuto checking for a prior crashed session with a recycled PID) - // already guard with `crashLock.pid !== process.pid` before calling us. - if (pid === process.pid) return true; - try { - process.kill(pid, 0); - return true; - } catch (err) { - // EPERM means the process exists but we lack permission — treat as alive. - // ESRCH means the process does not exist — treat as dead (stale lock). - if ((err as NodeJS.ErrnoException).code === "EPERM") return true; - return false; - } -} - -/** Format crash info for display or injection into a prompt. */ -export function formatCrashInfo(lock: LockData): string { - const lines = [ - `Previous auto-mode session was interrupted.`, - ` Was executing: ${lock.unitType} (${lock.unitId})`, - ` Started at: ${lock.unitStartedAt}`, - ` PID: ${lock.pid}`, - ]; - - // Add recovery guidance based on what was happening when it crashed - if (lock.unitType === "starting" && lock.unitId === "bootstrap") { - lines.push(`No work was lost. Run /gsd auto to restart.`); - } else if (lock.unitType.includes("research") || lock.unitType.includes("plan")) { - lines.push(`The ${lock.unitType} unit may be incomplete. Run /gsd auto to re-run it.`); - } else if (lock.unitType.includes("execute")) { - lines.push(`Task execution was interrupted. Run /gsd auto to resume — completed work is preserved.`); - } else if (lock.unitType.includes("complete")) { - lines.push(`Slice/milestone completion was interrupted. Run /gsd auto to finish.`); - } - - return lines.join("\n"); -} - -/** - * Emit a synthetic unit-end event for a unit that crashed without emitting its own. - * - * Queries the journal to find the most recent unit-start for the crashed unit. - * If a matching unit-end already exists (e.g. the hard timeout fired), this is a - * no-op. Called during crash recovery, before clearing the stale lock. - * - * Addresses the gap reported in #3348 where `unit-start` was emitted but no - * `unit-end` followed — side effects landed but the worker died before closeout. - */ -export function emitCrashRecoveredUnitEnd(basePath: string, lock: LockData): void { - // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event. - if (!lock.unitType || !lock.unitId || lock.unitType === "starting") return; - - try { - const all = queryJournal(basePath); - - // Find the most recent unit-start for this unitId - const starts = all.filter( - (e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId, - ); - if (starts.length === 0) return; - - const lastStart = starts[starts.length - 1]; - - // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash) - const alreadyClosed = all.some( - (e) => - e.eventType === "unit-end" && - e.data?.unitId === lock.unitId && - e.causedBy?.flowId === lastStart.flowId && - e.causedBy?.seq === lastStart.seq, - ); - if (alreadyClosed) return; - - // Find the highest seq in this flow for monotonic ordering - const maxSeq = all - .filter((e) => e.flowId === lastStart.flowId) - .reduce((max, e) => Math.max(max, e.seq), lastStart.seq); - - emitJournalEvent(basePath, { - ts: new Date().toISOString(), - flowId: lastStart.flowId, - seq: maxSeq + 1, - eventType: "unit-end", - data: { - unitType: lock.unitType, - unitId: lock.unitId, - status: "crash-recovered", - artifactVerified: false, - }, - causedBy: { flowId: lastStart.flowId, seq: lastStart.seq }, - }); - } catch { - // Never throw from crash recovery path — journal failure must not block recovery - } -} diff --git a/src/resources/extensions/gsd/custom-execution-policy.ts b/src/resources/extensions/gsd/custom-execution-policy.ts deleted file mode 100644 index 656873682..000000000 --- a/src/resources/extensions/gsd/custom-execution-policy.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * custom-execution-policy.ts — ExecutionPolicy for custom workflows. - * - * Delegates verification to the step-level verification module which reads - * the frozen DEFINITION.yaml and dispatches to the appropriate policy handler. - * - * Observability: - * - verify() returns the outcome from runCustomVerification() — four policies - * are supported: content-heuristic, shell-command, prompt-verify, human-review. - * - selectModel() returns null — defers to loop defaults. - * - recover() returns retry — simple default recovery strategy. - */ - -import type { ExecutionPolicy } from "./execution-policy.js"; -import type { RecoveryAction, CloseoutResult } from "./engine-types.js"; -import { runCustomVerification } from "./custom-verification.js"; -import { parseUnitId } from "./unit-id.js"; - -export class CustomExecutionPolicy implements ExecutionPolicy { - private readonly runDir: string; - - constructor(runDir: string) { - this.runDir = runDir; - } - - /** No workspace preparation needed for custom workflows. */ - async prepareWorkspace(_basePath: string, _milestoneId: string): Promise<void> { - // No-op — custom workflows don't need worktree setup - } - - /** Defer model selection to loop defaults. */ - async selectModel( - _unitType: string, - _unitId: string, - _context: { basePath: string }, - ): Promise<{ tier: string; modelDowngraded: boolean } | null> { - return null; - } - - /** - * Verify step output by dispatching to the step's configured verification policy. - * - * Extracts the step ID from unitId (format: "<workflowName>/<stepId>") - * and calls runCustomVerification() which reads the frozen DEFINITION.yaml - * to determine which policy to apply. - */ - async verify( - _unitType: string, - unitId: string, - _context: { basePath: string }, - ): Promise<"continue" | "retry" | "pause"> { - const { milestone, slice, task } = parseUnitId(unitId); - const stepId = task ?? slice ?? milestone; - return runCustomVerification(this.runDir, stepId); - } - - /** Default recovery: retry the step. */ - async recover( - _unitType: string, - _unitId: string, - _context: { basePath: string }, - ): Promise<RecoveryAction> { - return { outcome: "retry", reason: "Default retry" }; - } - - /** No-op closeout — no commits or artifact capture. */ - async closeout( - _unitType: string, - _unitId: string, - _context: { basePath: string; startedAt: number }, - ): Promise<CloseoutResult> { - return { committed: false, artifacts: [] }; - } -} diff --git a/src/resources/extensions/gsd/custom-verification.ts b/src/resources/extensions/gsd/custom-verification.ts deleted file mode 100644 index 931572a2c..000000000 --- a/src/resources/extensions/gsd/custom-verification.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * custom-verification.ts — Step verification for custom workflows. - * - * Reads the frozen DEFINITION.yaml from a run directory, finds the step's - * `verify` policy, and dispatches to the appropriate handler. Four policies: - * - * - content-heuristic: file existence + optional minSize + optional pattern match - * - shell-command: spawnSync with 30s timeout, exit 0 → continue, else retry - * - prompt-verify: always "pause" (defers to agent) - * - human-review: always "pause" (waits for manual inspection) - * - (no policy): returns "continue" (passthrough) - * - * Observability: - * - Return value is the typed verification outcome ("continue" | "retry" | "pause"). - * - shell-command captures stderr from spawnSync — callers can inspect on retry. - * - content-heuristic logs the specific failure (missing file, below minSize, pattern mismatch). - * - The frozen DEFINITION.yaml on disk is the single source of truth for step policies. - */ - -import { logWarning } from "./workflow-logger.js"; -import { readFileSync, existsSync, statSync } from "node:fs"; -import { join, resolve, sep } from "node:path"; -import { spawnSync } from "node:child_process"; -import type { StepDefinition, VerifyPolicy } from "./definition-loader.js"; -import { readFrozenDefinition } from "./custom-workflow-engine.js"; -import { rewriteCommandWithRtk } from "../shared/rtk.js"; - -/** Verification outcome type — matches ExecutionPolicy.verify() return type. */ -export type VerificationOutcome = "continue" | "retry" | "pause"; - -/** - * Run custom verification for a specific step in a workflow run. - * - * Reads the frozen DEFINITION.yaml from `runDir`, finds the step with the - * given `stepId`, and dispatches to the appropriate verification handler - * based on the step's `verify.policy` field. - * - * @param runDir — absolute path to the workflow run directory - * @param stepId — the step ID to verify (e.g. "step-1") - * @returns "continue" if verification passes, "retry" if it should retry, "pause" if it needs review - * @throws Error if DEFINITION.yaml is missing or unreadable - */ -export function runCustomVerification( - runDir: string, - stepId: string, -): VerificationOutcome { - const def = readFrozenDefinition(runDir); - - const step = def.steps.find((s: StepDefinition) => s.id === stepId); - if (!step) { - // Step not found in definition — nothing to verify, continue - return "continue"; - } - - if (!step.verify) { - // No verification policy configured — passthrough - return "continue"; - } - - return dispatchPolicy(runDir, step, step.verify); -} - -/** - * Dispatch to the correct policy handler. - */ -function dispatchPolicy( - runDir: string, - step: StepDefinition, - verify: VerifyPolicy, -): VerificationOutcome { - switch (verify.policy) { - case "content-heuristic": - return handleContentHeuristic(runDir, step, verify); - case "shell-command": - return handleShellCommand(runDir, verify); - case "prompt-verify": - return "pause"; - case "human-review": - return "pause"; - default: - // Unknown policy — safe default is pause - return "pause"; - } -} - -/** - * content-heuristic handler. - * - * For each path in the step's `produces` array: - * 1. Check that the file exists (resolved relative to runDir) - * 2. If `minSize` is set, check that file size >= minSize bytes - * 3. If `pattern` is set, check that file content matches the regex - * - * Returns "continue" if all checks pass, "pause" if any fail. - * If `produces` is empty or undefined, returns "continue" (nothing to check). - */ -function handleContentHeuristic( - runDir: string, - step: StepDefinition, - verify: { policy: "content-heuristic"; minSize?: number; pattern?: string }, -): VerificationOutcome { - const produces = step.produces; - if (!produces || produces.length === 0) { - return "continue"; - } - - for (const relPath of produces) { - const absPath = resolve(runDir, relPath); - // Path traversal guard - if (!absPath.startsWith(resolve(runDir) + sep) && absPath !== resolve(runDir)) { - return "pause"; - } - - // 1. File existence - if (!existsSync(absPath)) { - return "pause"; - } - - // 2. Minimum size check - if (verify.minSize !== undefined) { - const stat = statSync(absPath); - if (stat.size < verify.minSize) { - return "pause"; - } - } - - // 3. Pattern match check (with timeout guard against ReDoS) - if (verify.pattern !== undefined) { - const content = readFileSync(absPath, "utf-8"); - try { - if (!new RegExp(verify.pattern).test(content)) { - return "pause"; - } - } catch (e) { - logWarning("engine", `content-heuristic regex failed: ${(e as Error).message}`); - return "pause"; - } - } - } - - return "continue"; -} - -/** - * shell-command handler. - * - * Runs the command via `sh -c` with cwd set to the run directory - * and a 30-second timeout. Returns "continue" if exit code 0, - * "retry" otherwise (including timeout/signal kills). - * - * SECURITY: The command string comes from a frozen DEFINITION.yaml written - * at run-creation time. The trust boundary is the workflow definition author. - * Commands run with the same privileges as the SF process. Only use - * shell-command verification with definitions you trust. - */ -function handleShellCommand( - runDir: string, - verify: { policy: "shell-command"; command: string }, -): VerificationOutcome { - // Guard: reject commands containing shell expansion patterns that suggest injection - const dangerousPatterns = /\$\(|`|;\s*(rm|curl|wget|nc|bash|sh|eval)\b/; - if (dangerousPatterns.test(verify.command)) { - console.warn( - `custom-verification: shell-command contains suspicious pattern, skipping: ${verify.command}`, - ); - return "pause"; - } - - const rewrittenCommand = rewriteCommandWithRtk(verify.command); - const result = spawnSync("sh", ["-c", rewrittenCommand], { - cwd: runDir, - timeout: 30_000, - encoding: "utf-8", - stdio: "pipe", - env: { ...process.env, PATH: process.env.PATH }, - }); - - if (result.status === 0) { - return "continue"; - } - - return "retry"; -} diff --git a/src/resources/extensions/gsd/custom-workflow-engine.ts b/src/resources/extensions/gsd/custom-workflow-engine.ts deleted file mode 100644 index 53d520cb9..000000000 --- a/src/resources/extensions/gsd/custom-workflow-engine.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * custom-workflow-engine.ts — WorkflowEngine implementation for custom workflows. - * - * Drives the auto-loop using GRAPH.yaml step state from a run directory. - * Each iteration: deriveState reads the graph, resolveDispatch picks the - * next eligible step, reconcile marks it complete and persists. - * - * Observability: - * - All state reads/writes go through graph.ts YAML I/O — inspectable on disk. - * - `resolveDispatch` returns unitType "custom-step" with unitId "<name>/<stepId>". - * - `getDisplayMetadata` provides step N/M progress for dashboard rendering. - * - Phase transitions are derivable from GRAPH.yaml step statuses. - */ - -import type { WorkflowEngine } from "./workflow-engine.js"; -import type { - EngineState, - EngineDispatchAction, - CompletedStep, - ReconcileResult, - DisplayMetadata, -} from "./engine-types.js"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - readGraph, - writeGraph, - getNextPendingStep, - markStepComplete, - expandIteration, - type WorkflowGraph, -} from "./graph.js"; -import { injectContext } from "./context-injector.js"; -import type { StepDefinition } from "./definition-loader.js"; -import { readFrozenDefinition } from "./definition-io.js"; -import { parseUnitId } from "./unit-id.js"; -import { withFileLock } from "./file-lock.js"; - -// Re-export for downstream consumers -export { readFrozenDefinition } from "./definition-io.js"; - -export class CustomWorkflowEngine implements WorkflowEngine { - readonly engineId = "custom"; - private readonly runDir: string; - - constructor(runDir: string) { - this.runDir = runDir; - } - - /** - * Derive engine state from GRAPH.yaml on disk. - * - * Phase is "complete" when all steps are complete or expanded, - * "running" otherwise (any pending or active steps remain). - */ - async deriveState(_basePath: string): Promise<EngineState> { - const graph = readGraph(this.runDir); - const allDone = graph.steps.every( - (s) => s.status === "complete" || s.status === "expanded", - ); - const phase = allDone ? "complete" : "running"; - - return { - phase, - currentMilestoneId: null, - activeSliceId: null, - activeTaskId: null, - isComplete: allDone, - raw: graph, - }; - } - - /** - * Resolve the next dispatch action from graph state. - * - * Uses getNextPendingStep to find the first step whose dependencies - * are all satisfied. If the step has an `iterate` config in the frozen - * DEFINITION.yaml, expands it into instance steps before dispatching. - * - * Returns a dispatch with unitType "custom-step" and unitId in - * "<workflowName>/<stepId>" format. - * - * Observability: - * - Iterate expansion is logged to stderr with item count and parent step ID. - * - Missing source artifacts throw with the full resolved path for diagnosis. - * - Zero-match expansions return a stop action with level "info". - * - Expanded GRAPH.yaml is written to disk before dispatch — inspectable on disk. - */ - async resolveDispatch( - state: EngineState, - _context: { basePath: string }, - ): Promise<EngineDispatchAction> { - let graph = state.raw as WorkflowGraph; - let next = getNextPendingStep(graph); - - if (!next) { - return { - action: "stop", - reason: "All steps complete", - level: "info", - }; - } - - // Check frozen DEFINITION.yaml for iterate config on this step - const def = readFrozenDefinition(this.runDir); - const stepDef = def.steps.find((s: StepDefinition) => s.id === next!.id); - - if (stepDef?.iterate) { - const iterate = stepDef.iterate; - - // Read source artifact - const sourcePath = join(this.runDir, iterate.source); - let sourceContent: string; - try { - sourceContent = readFileSync(sourcePath, "utf-8"); - } catch { - throw new Error( - `Iterate source artifact not found: ${sourcePath} (step "${next.id}", source: "${iterate.source}")`, - ); - } - - // Extract items via regex with global+multiline flags. - // Guard against ReDoS: if matching takes too long on large inputs, bail. - const regex = new RegExp(iterate.pattern, "gm"); - const items: string[] = []; - const matchStart = Date.now(); - let match: RegExpExecArray | null; - while ((match = regex.exec(sourceContent)) !== null) { - if (match[1] !== undefined) items.push(match[1]); - if (Date.now() - matchStart > 5_000) { - throw new Error( - `Iterate pattern "${iterate.pattern}" exceeded 5s timeout on step "${next.id}" — possible ReDoS`, - ); - } - } - - // Expand the graph - const expandedGraph = expandIteration(graph, next.id, items, next.prompt); - writeGraph(this.runDir, expandedGraph); - graph = expandedGraph; - - // Re-query for first instance step - next = getNextPendingStep(expandedGraph); - - if (!next) { - return { - action: "stop", - reason: "Iterate expansion produced no instances", - level: "info", - }; - } - } - - // Enrich prompt with context from prior step artifacts - const enrichedPrompt = injectContext(this.runDir, next.id, next.prompt); - - return { - action: "dispatch", - step: { - unitType: "custom-step", - unitId: `${graph.metadata.name}/${next.id}`, - prompt: enrichedPrompt, - }, - }; - } - - /** - * Reconcile state after a step completes. - * - * Extracts the stepId from the completedStep's unitId (last segment after `/`), - * marks it complete in the graph, and writes the updated GRAPH.yaml to disk. - * - * Returns "milestone-complete" when all steps are now done, "continue" otherwise. - */ - async reconcile( - state: EngineState, - completedStep: CompletedStep, - ): Promise<ReconcileResult> { - const graphPath = join(this.runDir, "GRAPH.yaml"); - - return await withFileLock(graphPath, () => { - // Re-read the graph from disk so we do not overwrite concurrent - // workflow edits with a stale in-memory snapshot from deriveState(). - const graph = readGraph(this.runDir); - - // Extract stepId from "<workflowName>/<stepId>" - const { milestone, slice, task } = parseUnitId(completedStep.unitId); - const stepId = task ?? slice ?? milestone; - - const updatedGraph = markStepComplete(graph, stepId); - writeGraph(this.runDir, updatedGraph); - - const allDone = updatedGraph.steps.every( - (s) => s.status === "complete" || s.status === "expanded", - ); - - return { - outcome: allDone ? "milestone-complete" : "continue", - }; - }); - } - - /** - * Return UI-facing metadata for progress display. - * - * Shows "Step N/M" progress where N = completed count and M = total. - */ - getDisplayMetadata(state: EngineState): DisplayMetadata { - const graph = state.raw as WorkflowGraph; - const total = graph.steps.length; - const completed = graph.steps.filter((s) => s.status === "complete").length; - - return { - engineLabel: "WORKFLOW", - currentPhase: state.phase, - progressSummary: `Step ${completed}/${total}`, - stepCount: { completed, total }, - }; - } -} diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts deleted file mode 100644 index aa47663dc..000000000 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ /dev/null @@ -1,666 +0,0 @@ -/** - * SF Dashboard Overlay - * - * Full-screen overlay showing auto-mode progress: milestone/slice/task - * breakdown, current unit, completed units, timing, and activity log. - * Toggled with Ctrl+Alt+G (⌃⌥G on macOS), Ctrl+Shift+G fallback, - * or opened from /gsd status. - */ - -import type { Theme } from "@sf-run/pi-coding-agent"; -import { truncateToWidth, visibleWidth, matchesKey, Key } from "@sf-run/pi-tui"; -import { deriveState } from "./state.js"; -import { loadFile } from "./files.js"; -import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; -import { resolveMilestoneFile, resolveSliceFile } from "./paths.js"; -import { getAutoDashboardData } from "./auto.js"; -import type { AutoDashboardData } from "./auto-dashboard.js"; -import { - getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, - aggregateByModel, aggregateCacheHitRate, formatCost, formatTokenCount, formatCostProjection, - type UnitMetrics, -} from "./metrics.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { getActiveWorktreeName } from "./worktree-command.js"; -import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js"; -import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js"; -import { estimateTimeRemaining } from "./auto-dashboard.js"; -import { computeProgressScore, formatProgressLine } from "./progress-score.js"; -import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js"; -import { formattedShortcutPair } from "./shortcut-defs.js"; - -function unitLabel(type: string): string { - switch (type) { - case "discuss-milestone": - case "discuss-slice": return "Discuss"; - case "research-milestone": return "Research"; - case "plan-milestone": return "Plan"; - case "research-slice": return "Research"; - case "plan-slice": return "Plan"; - case "execute-task": return "Execute"; - case "complete-slice": return "Complete"; - case "reassess-roadmap": return "Reassess"; - case "triage-captures": return "Triage"; - case "quick-task": return "Quick Task"; - case "replan-slice": return "Replan"; - case "custom-step": return "Workflow Step"; - default: return type; - } -} - - -export class GSDDashboardOverlay { - private tui: { requestRender: () => void }; - private theme: Theme; - private onClose: () => void; - private cachedWidth?: number; - private cachedLines?: string[]; - private refreshTimer: ReturnType<typeof setInterval>; - private scrollOffset = 0; - private dashData: AutoDashboardData; - private milestoneData: MilestoneView | null = null; - private loading = true; - private loadedDashboardIdentity?: string; - private refreshInFlight: Promise<void> | null = null; - private disposed = false; - private resizeHandler: (() => void) | null = null; - - constructor( - tui: { requestRender: () => void }, - theme: Theme, - onClose: () => void, - ) { - this.tui = tui; - this.theme = theme; - this.onClose = onClose; - this.dashData = getAutoDashboardData(); - - // Invalidate cache on terminal resize - this.resizeHandler = () => { - if (this.disposed) return; - this.invalidate(); - this.tui.requestRender(); - }; - process.stdout.on("resize", this.resizeHandler); - - this.scheduleRefresh(true); - - this.refreshTimer = setInterval(() => { - this.scheduleRefresh(); - }, 2000); - } - - private scheduleRefresh(initial = false): void { - if (this.refreshInFlight || this.disposed) return; - this.refreshInFlight = this.refreshDashboard(initial) - .finally(() => { - this.refreshInFlight = null; - }); - } - - private computeDashboardIdentity(dashData: AutoDashboardData): string { - const base = dashData.basePath || process.cwd(); - const currentUnit = dashData.currentUnit - ? `${dashData.currentUnit.type}:${dashData.currentUnit.id}:${dashData.currentUnit.startedAt}` - : "-"; - return [ - base, - dashData.active ? "1" : "0", - dashData.paused ? "1" : "0", - currentUnit, - ].join("|"); - } - - private async refreshDashboard(initial = false): Promise<void> { - if (this.disposed) return; - this.dashData = getAutoDashboardData(); - const nextIdentity = this.computeDashboardIdentity(this.dashData); - - if (initial || nextIdentity !== this.loadedDashboardIdentity) { - const loaded = await this.loadData(); - if (this.disposed) return; - if (loaded) { - this.loadedDashboardIdentity = nextIdentity; - } - } - - if (initial) { - this.loading = false; - } - - this.invalidate(); - this.tui.requestRender(); - } - - private async loadData(): Promise<boolean> { - const base = this.dashData.basePath || process.cwd(); - try { - const state = await deriveState(base); - if (!state.activeMilestone) { - this.milestoneData = null; - return true; - } - - const mid = state.activeMilestone.id; - const view: MilestoneView = { - id: mid, - title: state.activeMilestone.title, - slices: [], - phase: state.phase, - progress: { - milestones: { - total: state.progress?.milestones.total ?? state.registry.length, - done: state.progress?.milestones.done ?? state.registry.filter(entry => entry.status === "complete").length, - }, - }, - }; - - const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); - const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - // Normalize slices from DB - type NormSlice = { id: string; done: boolean; title: string; risk: string }; - let normSlices: NormSlice[] = []; - if (isDbAvailable()) { - normSlices = getMilestoneSlices(mid).map(s => ({ id: s.id, done: s.status === "complete", title: s.title, risk: s.risk || "medium" })); - } - - for (const s of normSlices) { - const sliceView: SliceView = { - id: s.id, - title: s.title, - done: s.done, - risk: s.risk, - active: state.activeSlice?.id === s.id, - tasks: [], - }; - - if (sliceView.active) { - // Normalize tasks from DB - if (isDbAvailable()) { - const dbTasks = getSliceTasks(mid, s.id); - sliceView.taskProgress = { - done: dbTasks.filter(t => t.status === "complete" || t.status === "done").length, - total: dbTasks.length, - }; - for (const t of dbTasks) { - sliceView.tasks.push({ - id: t.id, - title: t.title, - done: t.status === "complete" || t.status === "done", - active: state.activeTask?.id === t.id, - }); - } - } - } - - view.slices.push(sliceView); - } - - this.milestoneData = view; - return true; - } catch { - // Don't crash the overlay - return false; - } - } - - handleInput(data: string): void { - if ( - matchesKey(data, Key.escape) || - matchesKey(data, Key.ctrl("c")) || - matchesKey(data, Key.ctrlAlt("g")) || - matchesKey(data, Key.ctrlShift("g")) - ) { - this.dispose(); - this.onClose(); - return; - } - - if (matchesKey(data, Key.down) || matchesKey(data, "j")) { - this.scrollOffset++; - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (matchesKey(data, Key.up) || matchesKey(data, "k")) { - this.scrollOffset = Math.max(0, this.scrollOffset - 1); - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (data === "g") { - this.scrollOffset = 0; - this.invalidate(); - this.tui.requestRender(); - return; - } - - if (data === "G") { - this.scrollOffset = 999; - this.invalidate(); - this.tui.requestRender(); - return; - } - } - - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; - } - - const content = this.buildContentLines(width); - const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24); - const chromeHeight = 2; - const visibleContentRows = Math.max(1, viewportHeight - chromeHeight); - const maxScroll = Math.max(0, content.length - visibleContentRows); - this.scrollOffset = Math.min(this.scrollOffset, maxScroll); - const visibleContent = content.slice(this.scrollOffset, this.scrollOffset + visibleContentRows); - - const lines = this.wrapInBox(visibleContent, width); - - this.cachedWidth = width; - this.cachedLines = lines; - return lines; - } - - private wrapInBox(inner: string[], width: number): string[] { - const th = this.theme; - const border = (s: string) => th.fg("borderAccent", s); - const innerWidth = width - 4; - const lines: string[] = []; - - lines.push(border("╭" + "─".repeat(width - 2) + "╮")); - for (const line of inner) { - const truncated = truncateToWidth(line, innerWidth); - const padWidth = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│")); - } - lines.push(border("╰" + "─".repeat(width - 2) + "╯")); - return lines; - } - - private buildContentLines(width: number): string[] { - const th = this.theme; - const shellWidth = width - 4; - const contentWidth = Math.min(shellWidth, 128); - const sidePad = Math.max(0, Math.floor((shellWidth - contentWidth) / 2)); - const leftMargin = " ".repeat(sidePad); - const lines: string[] = []; - - const row = (content = ""): string => { - const truncated = truncateToWidth(content, contentWidth); - return leftMargin + padRight(truncated, contentWidth); - }; - const blank = () => row(""); - const hr = () => row(th.fg("dim", "─".repeat(contentWidth))); - const centered = (content: string) => row(centerLine(content, contentWidth)); - - const title = th.fg("accent", th.bold("SF Dashboard")); - const isRemote = !!this.dashData.remoteSession; - const status = this.dashData.active - ? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")}` - : this.dashData.paused - ? th.fg("warning", "⏸ PAUSED") - : isRemote - ? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")} ${th.fg("dim", `(PID ${this.dashData.remoteSession!.pid})`)}` - : th.fg("dim", "idle"); - const worktreeName = getActiveWorktreeName(); - const worktreeTag = worktreeName - ? ` ${th.fg("warning", `⎇ ${worktreeName}`)}` - : ""; - let elapsedParts = ""; - if (this.dashData.active || this.dashData.paused) { - // Guard: skip display when elapsed is zero or unreasonably large (>30 days) - const elapsed = this.dashData.elapsed; - elapsedParts = elapsed > 0 && elapsed < 30 * 24 * 3600_000 - ? th.fg("dim", formatDuration(elapsed)) - : ""; - const eta = estimateTimeRemaining(); - if (eta) elapsedParts += th.fg("dim", ` · ${eta}`); - } else if (isRemote) { - elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`); - } - lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth))); - - // Progress score — traffic light indicator (#1221) - if (this.dashData.active || this.dashData.paused) { - const progressScore = computeProgressScore(); - const progressIcon = progressScore.level === "green" ? th.fg("success", "●") - : progressScore.level === "yellow" ? th.fg("warning", "●") - : th.fg("error", "●"); - lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`)); - - // Show signal details when degraded — real-time visibility into what doctor found - if (progressScore.level !== "green" && progressScore.signals.length > 0) { - for (const signal of progressScore.signals) { - const prefix = signal.kind === "positive" ? th.fg("success", " ✓") - : signal.kind === "negative" ? th.fg("error", " ✗") - : th.fg("dim", " ·"); - lines.push(row(`${prefix} ${th.fg("dim", signal.label)}`)); - } - } - } - lines.push(blank()); - - if (this.dashData.currentUnit) { - const cu = this.dashData.currentUnit; - const currentElapsed = th.fg("dim", formatDuration(Date.now() - cu.startedAt)); - lines.push(row(joinColumns( - `${th.fg("text", "Now")}: ${th.fg("accent", unitLabel(cu.type))} ${th.fg("text", cu.id)}`, - currentElapsed, - contentWidth, - ))); - lines.push(blank()); - } else if (this.dashData.paused) { - lines.push(row(th.fg("dim", "/gsd auto to resume"))); - lines.push(blank()); - } else if (isRemote) { - const rs = this.dashData.remoteSession!; - const unitDisplay = rs.unitType === "starting" || rs.unitType === "resuming" - ? rs.unitType - : `${unitLabel(rs.unitType)} ${rs.unitId}`; - lines.push(row(th.fg("text", `Remote session: ${unitDisplay}`))); - lines.push(blank()); - } else { - lines.push(row(th.fg("dim", "No unit running · /gsd auto to start"))); - lines.push(blank()); - } - - // Parallel workers section — shows active subagent sessions - if (hasActiveWorkers()) { - lines.push(hr()); - lines.push(row(th.fg("text", th.bold("Parallel Workers")))); - lines.push(blank()); - - const batches = getWorkerBatches(); - for (const [batchId, workers] of batches) { - const running = workers.filter(w => w.status === "running").length; - const done = workers.filter(w => w.status === "completed").length; - const failed = workers.filter(w => w.status === "failed").length; - const total = workers[0]?.batchSize ?? workers.length; - - lines.push(row(joinColumns( - ` ${th.fg("accent", "⟐")} ${th.fg("text", `Batch ${batchId.slice(0, 8)}`)}`, - th.fg("dim", `${done + failed}/${total} done`), - contentWidth, - ))); - - for (const w of workers) { - const icon = w.status === "running" - ? th.fg("accent", "▸") - : w.status === "completed" - ? th.fg("success", "✓") - : th.fg("error", "✗"); - const elapsed = th.fg("dim", formatDuration(Date.now() - w.startedAt)); - const taskPreview = truncateToWidth(w.task, Math.max(20, contentWidth - 30)); - lines.push(row(joinColumns( - ` ${icon} ${th.fg("text", w.agent)} ${th.fg("dim", taskPreview)}`, - elapsed, - contentWidth, - ))); - } - } - lines.push(blank()); - } - - // Pending captures badge — only shown when captures are waiting for triage - if (this.dashData.pendingCaptureCount > 0) { - const count = this.dashData.pendingCaptureCount; - lines.push(row(th.fg("warning", `📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`))); - lines.push(blank()); - } - - if (this.loading) { - lines.push(centered(th.fg("dim", "Loading dashboard…"))); - return lines; - } - - if (this.milestoneData) { - const mv = this.milestoneData; - lines.push(row(th.fg("text", th.bold(`${mv.id}: ${mv.title}`)))); - lines.push(blank()); - - const totalSlices = mv.slices.length; - const doneSlices = mv.slices.filter(s => s.done).length; - const totalMilestones = mv.progress.milestones.total; - const doneMilestones = mv.progress.milestones.done; - const activeSlice = mv.slices.find(s => s.active); - - lines.push(blank()); - - if (activeSlice?.taskProgress) { - lines.push(row(this.renderProgressRow("Tasks", activeSlice.taskProgress.done, activeSlice.taskProgress.total, "accent", contentWidth))); - } - lines.push(row(this.renderProgressRow("Slices", doneSlices, totalSlices, "success", contentWidth))); - lines.push(row(this.renderProgressRow("Milestones", doneMilestones, totalMilestones, "warning", contentWidth))); - - lines.push(blank()); - - for (const s of mv.slices) { - const sliceStatus = s.done ? "done" : s.active ? "active" : "pending"; - const icon = th.fg(STATUS_COLOR[sliceStatus], STATUS_GLYPH[sliceStatus]); - const titleColor = s.active ? "accent" : s.done ? "muted" : "dim"; - const titleText = th.fg(titleColor, `${s.id}: ${s.title}`); - const risk = th.fg("dim", s.risk); - lines.push(row(joinColumns(` ${icon} ${titleText}`, risk, contentWidth))); - - if (s.active && s.tasks.length > 0) { - for (const t of s.tasks) { - const taskStatus = t.done ? "done" : t.active ? "active" : "pending"; - const tIcon = th.fg(STATUS_COLOR[taskStatus], STATUS_GLYPH[taskStatus]); - const tColor = t.active ? "warning" : t.done ? "muted" : "dim"; - const tTitle = th.fg(tColor, `${t.id}: ${t.title}`); - lines.push(row(` ${tIcon} ${truncateToWidth(tTitle, contentWidth - 6)}`)); - } - } - } - } else { - lines.push(centered(th.fg("dim", "No active milestone."))); - } - - const ledger = getLedger(); - if (ledger && ledger.units.length > 0) { - const totals = getProjectTotals(ledger.units); - - lines.push(blank()); - lines.push(hr()); - lines.push(row(th.fg("text", th.bold("Cost & Usage")))); - lines.push(blank()); - - // Show cost or request count (for copilot/subscription users where cost is 0) - const costOrReqs = totals.cost > 0 - ? `${th.fg("warning", formatCost(totals.cost))} total` - : `${th.fg("text", String(totals.apiRequests))} requests`; - lines.push(row(fitColumns([ - costOrReqs, - `${th.fg("text", formatTokenCount(totals.tokens.total))} tokens`, - `${th.fg("text", String(totals.toolCalls))} tools`, - `${th.fg("text", String(totals.units))} units`, - ], contentWidth, ` ${th.fg("dim", "·")} `))); - - lines.push(row(fitColumns([ - `${th.fg("dim", "in:")} ${th.fg("text", formatTokenCount(totals.tokens.input))}`, - `${th.fg("dim", "out:")} ${th.fg("text", formatTokenCount(totals.tokens.output))}`, - `${th.fg("dim", "cache-r:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheRead))}`, - `${th.fg("dim", "cache-w:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheWrite))}`, - ], contentWidth, " "))); - - // Budget aggregate line — only when data exists - if (totals.totalTruncationSections > 0 || totals.continueHereFiredCount > 0) { - const budgetParts: string[] = []; - if (totals.totalTruncationSections > 0) { - budgetParts.push(th.fg("warning", `${totals.totalTruncationSections} sections truncated`)); - } - if (totals.continueHereFiredCount > 0) { - budgetParts.push(th.fg("error", `${totals.continueHereFiredCount} continue-here fired`)); - } - lines.push(row(budgetParts.join(` ${th.fg("dim", "·")} `))); - } - - const phases = aggregateByPhase(ledger.units); - if (phases.length > 0) { - lines.push(blank()); - lines.push(row(th.fg("dim", "By Phase"))); - for (const p of phases) { - const pct = totals.cost > 0 ? Math.round((p.cost / totals.cost) * 100) : 0; - const left = ` ${th.fg("text", p.phase.padEnd(14))}${th.fg("warning", formatCost(p.cost).padStart(8))}`; - const right = th.fg("dim", `${String(pct).padStart(3)}% ${formatTokenCount(p.tokens.total)} tok ${p.units} units`); - lines.push(row(joinColumns(left, right, contentWidth))); - } - } - - const slices = aggregateBySlice(ledger.units); - if (slices.length > 0) { - lines.push(blank()); - lines.push(row(th.fg("dim", "By Slice"))); - for (const s of slices) { - const pct = totals.cost > 0 ? Math.round((s.cost / totals.cost) * 100) : 0; - const left = ` ${th.fg("text", s.sliceId.padEnd(14))}${th.fg("warning", formatCost(s.cost).padStart(8))}`; - const right = th.fg("dim", `${String(pct).padStart(3)}% ${formatTokenCount(s.tokens.total)} tok ${formatDuration(s.duration)}`); - lines.push(row(joinColumns(left, right, contentWidth))); - } - } - - // Cost projection — only when active milestone data is available - if (this.milestoneData) { - const mv = this.milestoneData; - const msTotalSlices = mv.slices.length; - const msDoneSlices = mv.slices.filter(s => s.done).length; - const remainingCount = msTotalSlices - msDoneSlices; - const overlayPrefs = loadEffectiveGSDPreferences()?.preferences; - const projLines = formatCostProjection(slices, remainingCount, overlayPrefs?.budget_ceiling); - if (projLines.length > 0) { - lines.push(blank()); - for (const line of projLines) { - const colored = line.toLowerCase().includes('ceiling') - ? th.fg("warning", line) - : th.fg("dim", line); - lines.push(row(colored)); - } - } - } - - const models = aggregateByModel(ledger.units); - if (models.length >= 1) { - lines.push(blank()); - lines.push(row(th.fg("dim", "By Model"))); - for (const m of models) { - const pct = totals.cost > 0 ? Math.round((m.cost / totals.cost) * 100) : 0; - const modelName = truncateToWidth(m.model, 38); - const ctxWindow = m.contextWindowTokens !== undefined - ? th.fg("dim", ` [${formatTokenCount(m.contextWindowTokens)}]`) - : ""; - const left = ` ${th.fg("text", modelName.padEnd(38))}${th.fg("warning", formatCost(m.cost).padStart(8))}`; - const right = th.fg("dim", `${String(pct).padStart(3)}% ${m.units} units`) + ctxWindow; - lines.push(row(joinColumns(left, right, contentWidth))); - } - } - - lines.push(blank()); - lines.push(row(`${th.fg("dim", "avg/unit:")} ${th.fg("text", formatCost(totals.cost / totals.units))} ${th.fg("dim", "·")} ${th.fg("text", formatTokenCount(Math.round(totals.tokens.total / totals.units)))} tokens`)); - - // Cache hit rate - const cacheRate = aggregateCacheHitRate(); - if (cacheRate > 0) { - lines.push(row(`${th.fg("dim", "cache hit rate:")} ${th.fg("text", `${cacheRate}%`)}`)); - } - - if (this.dashData.rtkEnabled && this.dashData.rtkSavings && this.dashData.rtkSavings.commands > 0) { - const rtk = this.dashData.rtkSavings; - lines.push(row( - `${th.fg("dim", "rtk saved:")} ${th.fg("text", formatTokenCount(rtk.savedTokens))} ${th.fg("dim", `(${Math.round(rtk.savingsPct)}% · ${rtk.commands} cmd${rtk.commands === 1 ? "" : "s"})`)}`, - )); - } - } - - // Environment health section (#1221) — only show issues - const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd()); - const envIssues = envResults.filter(r => r.status !== "ok"); - if (envIssues.length > 0) { - lines.push(blank()); - lines.push(hr()); - lines.push(row(th.fg("text", th.bold("Environment")))); - lines.push(blank()); - for (const r of envIssues) { - const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠"); - lines.push(row(` ${icon} ${th.fg("text", r.message)}`)); - if (r.detail) { - lines.push(row(th.fg("dim", ` ${r.detail}`))); - } - } - } - - lines.push(blank()); - lines.push(hr()); - lines.push(centered(th.fg("dim", `↑↓ scroll · g/G top/end · Esc/${formattedShortcutPair("dashboard")} close`))); - - return lines; - } - - private renderProgressRow( - label: string, - done: number, - total: number, - color: "success" | "accent" | "warning", - width: number, - ): string { - const th = this.theme; - const pct = total > 0 ? Math.round((done / total) * 100) : 0; - const labelWidth = 12; - const rightWidth = 14; - const gap = 2; - const labelText = truncateToWidth(label, labelWidth, "").padEnd(labelWidth); - const ratioText = `${done}/${total}`; - const rightText = `${String(pct).padStart(3)}% ${ratioText.padStart(rightWidth - 5)}`; - const barWidth = Math.max(12, width - labelWidth - rightWidth - gap * 2); - const filled = total > 0 ? Math.round((done / total) * barWidth) : 0; - const bar = th.fg(color, "█".repeat(filled)) + th.fg("dim", "░".repeat(Math.max(0, barWidth - filled))); - return `${th.fg("dim", labelText)}${" ".repeat(gap)}${bar}${" ".repeat(gap)}${th.fg("dim", rightText)}`; - } - - invalidate(): void { - this.cachedWidth = undefined; - this.cachedLines = undefined; - } - - dispose(): void { - this.disposed = true; - clearInterval(this.refreshTimer); - if (this.resizeHandler) { - process.stdout.removeListener("resize", this.resizeHandler); - this.resizeHandler = null; - } - } -} - -interface MilestoneView { - id: string; - title: string; - slices: SliceView[]; - phase: string; - progress: { - milestones: { - total: number; - done: number; - }; - }; -} - -interface SliceView { - id: string; - title: string; - done: boolean; - risk: string; - active: boolean; - tasks: TaskView[]; - taskProgress?: { done: number; total: number }; -} - -interface TaskView { - id: string; - title: string; - done: boolean; - active: boolean; -} diff --git a/src/resources/extensions/gsd/db-writer.ts b/src/resources/extensions/gsd/db-writer.ts deleted file mode 100644 index b7950bb7e..000000000 --- a/src/resources/extensions/gsd/db-writer.ts +++ /dev/null @@ -1,729 +0,0 @@ -// SF DB Writer — Markdown generators + DB-first write helpers -// -// The missing DB→markdown direction. S03 established markdown→DB (md-importer.ts). -// This module generates DECISIONS.md and REQUIREMENTS.md from DB state, -// computes next decision IDs, and provides write helpers that upsert to DB -// then regenerate the corresponding markdown file. -// -// Critical invariant: generated markdown must round-trip through -// parseDecisionsTable() and parseRequirementsSections() with field fidelity. - -import { join, resolve } from 'node:path'; -import { readFileSync, existsSync, statSync } from 'node:fs'; -import type { Decision, Requirement } from './types.js'; -import { resolveGsdRootFile } from './paths.js'; -import { saveFile } from './files.js'; -import { GSDError, SF_STALE_STATE, SF_IO_ERROR } from './errors.js'; -import { logWarning, logError } from './workflow-logger.js'; -import { invalidateStateCache } from './state.js'; -import { clearPathCache } from './paths.js'; -import { clearParseCache } from './files.js'; - -// ─── Freeform Detection ─────────────────────────────────────────────────── - -/** - * Detect whether a DECISIONS.md file is in canonical table format - * (generated by generateDecisionsMd). - * - * Returns true only if the file starts with the canonical header - * ("# Decisions Register") that generateDecisionsMd produces. - * Files with freeform content — even if they contain an appended - * decisions table section — return false so the freeform content - * is preserved. - */ -export function isDecisionsTableFormat(content: string): boolean { - // The canonical format always starts with "# Decisions Register" - const firstLine = content.split('\n')[0]?.trim() ?? ''; - if (firstLine !== '# Decisions Register') return false; - - // Additionally verify the file has the canonical table header - return content.includes('| # | When | Scope | Decision | Choice | Rationale | Revisable?'); -} - -/** - * Generate a minimal decisions table section (header + rows) for appending - * to a freeform DECISIONS.md file. - */ -function generateDecisionsAppendBlock(decisions: Decision[]): string { - const lines: string[] = []; - lines.push(''); - lines.push('---'); - lines.push(''); - lines.push('## Decisions Table'); - lines.push(''); - lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'); - lines.push('|---|------|-------|----------|--------|-----------|------------|---------|'); - - for (const d of decisions) { - const cells = [ - d.id, - d.when_context, - d.scope, - d.decision, - d.choice, - d.rationale, - d.revisable, - d.made_by ?? 'agent', - ].map(cell => (cell ?? '').replace(/\|/g, '\\|')); - lines.push(`| ${cells.join(' | ')} |`); - } - - return lines.join('\n') + '\n'; -} - -// ─── Markdown Generators ────────────────────────────────────────────────── - -/** - * Generate full DECISIONS.md content from an array of Decision objects. - * Produces the canonical format: H1 header, HTML comment block, table header, - * separator, and one data row per decision. - * - * Column order: #, When, Scope, Decision, Choice, Rationale, Revisable? - */ -export function generateDecisionsMd(decisions: Decision[]): string { - const lines: string[] = []; - - lines.push('# Decisions Register'); - lines.push(''); - lines.push('<!-- Append-only. Never edit or remove existing rows.'); - lines.push(' To reverse a decision, add a new row that supersedes it.'); - lines.push(' Read this file at the start of any planning or research phase. -->'); - lines.push(''); - lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |'); - lines.push('|---|------|-------|----------|--------|-----------|------------|---------|'); - - for (const d of decisions) { - // Escape pipe characters within cell values to preserve table structure - const cells = [ - d.id, - d.when_context, - d.scope, - d.decision, - d.choice, - d.rationale, - d.revisable, - d.made_by ?? 'agent', - ].map(cell => (cell ?? '').replace(/\|/g, '\\|')); - - lines.push(`| ${cells.join(' | ')} |`); - } - - return lines.join('\n') + '\n'; -} - -// ─── Requirements Markdown Generator ────────────────────────────────────── - -/** Status values that map to specific sections, in display order. */ -const STATUS_SECTION_MAP: Array<{ status: string; heading: string }> = [ - { status: 'active', heading: 'Active' }, - { status: 'validated', heading: 'Validated' }, - { status: 'deferred', heading: 'Deferred' }, - { status: 'out-of-scope', heading: 'Out of Scope' }, -]; - -/** - * Generate full REQUIREMENTS.md content from an array of Requirement objects. - * Groups requirements by status into sections (## Active, ## Validated, etc.), - * each containing ### RXXX — Description headings with bullet fields. - * Only emits sections that have content. Appends Traceability table and - * Coverage Summary at the bottom. - */ -export function generateRequirementsMd(requirements: Requirement[]): string { - const lines: string[] = []; - - lines.push('# Requirements'); - lines.push(''); - lines.push('This file is the explicit capability and coverage contract for the project.'); - lines.push(''); - - // Group by status - const byStatus = new Map<string, Requirement[]>(); - for (const r of requirements) { - const status = (r.status || 'active').toLowerCase(); - if (!byStatus.has(status)) byStatus.set(status, []); - byStatus.get(status)!.push(r); - } - - // Emit sections in canonical order - for (const { status, heading } of STATUS_SECTION_MAP) { - const reqs = byStatus.get(status); - if (!reqs || reqs.length === 0) continue; - - lines.push(`## ${heading}`); - lines.push(''); - - for (const r of reqs) { - lines.push(`### ${r.id} — ${r.description || 'Untitled'}`); - - // Emit bullet fields — only those with content - if (r.class) lines.push(`- Class: ${r.class}`); - if (r.status) lines.push(`- Status: ${r.status}`); - if (r.description) lines.push(`- Description: ${r.description}`); - if (r.why) lines.push(`- Why it matters: ${r.why}`); - if (r.source) lines.push(`- Source: ${r.source}`); - if (r.primary_owner) lines.push(`- Primary owning slice: ${r.primary_owner}`); - if (r.supporting_slices) lines.push(`- Supporting slices: ${r.supporting_slices}`); - if (r.validation) lines.push(`- Validation: ${r.validation}`); - if (r.notes) lines.push(`- Notes: ${r.notes}`); - lines.push(''); - } - } - - // Traceability table - lines.push('## Traceability'); - lines.push(''); - lines.push('| ID | Class | Status | Primary owner | Supporting | Proof |'); - lines.push('|---|---|---|---|---|---|'); - - for (const r of requirements) { - const proof = r.validation || 'unmapped'; - lines.push( - `| ${r.id} | ${r.class || ''} | ${r.status || ''} | ${r.primary_owner || 'none'} | ${r.supporting_slices || 'none'} | ${proof} |`, - ); - } - - lines.push(''); - - // Coverage Summary - const activeCount = byStatus.get('active')?.length ?? 0; - const validatedReqs = byStatus.get('validated') ?? []; - const validatedIds = validatedReqs.map(r => r.id).join(', '); - - lines.push('## Coverage Summary'); - lines.push(''); - lines.push(`- Active requirements: ${activeCount}`); - lines.push(`- Mapped to slices: ${activeCount}`); - lines.push(`- Validated: ${validatedReqs.length}${validatedIds ? ` (${validatedIds})` : ''}`); - lines.push(`- Unmapped active requirements: 0`); - - return lines.join('\n') + '\n'; -} - -// ─── Next Decision ID ───────────────────────────────────────────────────── - -/** - * Compute the next decision ID from the current DB state. - * Queries MAX(CAST(SUBSTR(id, 2) AS INTEGER)) from decisions table. - * Returns D001 if no decisions exist. Zero-pads to 3 digits. - */ -export async function nextDecisionId(): Promise<string> { - try { - const db = await import('./gsd-db.js'); - const adapter = db._getAdapter(); - if (!adapter) return 'D001'; - - const row = adapter - .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions') - .get(); - - const maxNum = row ? (row['max_num'] as number | null) : null; - if (maxNum == null || isNaN(maxNum)) return 'D001'; - - const next = maxNum + 1; - return `D${String(next).padStart(3, '0')}`; - } catch (err) { - logError('manifest', 'nextDecisionId failed', { fn: 'nextDecisionId', error: String((err as Error).message) }); - return 'D001'; - } -} - -// ─── Next Requirement ID ───────────────────────────────────────────────── - -/** - * Compute the next requirement ID from the current DB state. - * Queries MAX(CAST(SUBSTR(id, 2) AS INTEGER)) from requirements table. - * Returns R001 if no requirements exist. Zero-pads to 3 digits. - */ -export async function nextRequirementId(): Promise<string> { - try { - const db = await import('./gsd-db.js'); - const adapter = db._getAdapter(); - if (!adapter) return 'R001'; - - const row = adapter - .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements') - .get(); - - const maxNum = row ? (row['max_num'] as number | null) : null; - if (maxNum == null || isNaN(maxNum)) return 'R001'; - - const next = maxNum + 1; - return `R${String(next).padStart(3, '0')}`; - } catch (err) { - logError('manifest', 'nextRequirementId failed', { fn: 'nextRequirementId', error: String((err as Error).message) }); - return 'R001'; - } -} - -// ─── Save Requirement to DB + Regenerate Markdown ──────────────────────── - -export interface SaveRequirementFields { - class: string; - status?: string; - description: string; - why: string; - source: string; - primary_owner?: string; - supporting_slices?: string; - validation?: string; - notes?: string; -} - -/** - * Save a new requirement to DB and regenerate REQUIREMENTS.md. - * Auto-assigns the next ID via nextRequirementId(). - * - * The ID computation and insert are wrapped in a single transaction - * to prevent parallel race conditions (same pattern as saveDecisionToDb). - * - * Returns the assigned ID. - */ -export async function saveRequirementToDb( - fields: SaveRequirementFields, - basePath: string, -): Promise<{ id: string }> { - try { - const db = await import('./gsd-db.js'); - - // Atomic ID assignment + insert inside a transaction. - const id = db.transaction(() => { - const adapter = db._getAdapter(); - if (!adapter) throw new GSDError(SF_STALE_STATE, "gsd-db: No database open"); - - const row = adapter - .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements') - .get(); - const maxNum = row ? (row['max_num'] as number | null) : null; - const nextId = (maxNum == null || isNaN(maxNum)) - ? 'R001' - : `R${String(maxNum + 1).padStart(3, '0')}`; - - const requirement: Requirement = { - id: nextId, - class: fields.class, - status: fields.status ?? 'active', - description: fields.description, - why: fields.why, - source: fields.source, - primary_owner: fields.primary_owner ?? '', - supporting_slices: fields.supporting_slices ?? '', - validation: fields.validation ?? '', - notes: fields.notes ?? '', - full_content: '', - superseded_by: null, - }; - - db.upsertRequirement(requirement); - return nextId; - }); - - // Fetch all requirements for full file regeneration - const adapter = db._getAdapter(); - let allRequirements: Requirement[] = []; - if (adapter) { - const rows = adapter.prepare('SELECT * FROM requirements ORDER BY id').all(); - allRequirements = rows.map(row => ({ - id: row['id'] as string, - class: row['class'] as string, - status: row['status'] as string, - description: row['description'] as string, - why: row['why'] as string, - source: row['source'] as string, - primary_owner: row['primary_owner'] as string, - supporting_slices: row['supporting_slices'] as string, - validation: row['validation'] as string, - notes: row['notes'] as string, - full_content: row['full_content'] as string, - superseded_by: (row['superseded_by'] as string) ?? null, - })); - } - - const nonSuperseded = allRequirements.filter(r => r.superseded_by == null); - const md = generateRequirementsMd(nonSuperseded); - const filePath = resolveGsdRootFile(basePath, 'REQUIREMENTS'); - try { - await saveFile(filePath, md); - } catch (diskErr) { - logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveRequirementToDb', error: String((diskErr as Error).message) }); - try { - db.deleteRequirementById(id); - } catch (rollbackErr) { - logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveRequirementToDb', id, error: String((rollbackErr as Error).message) }); - } - throw diskErr; - } - invalidateStateCache(); - clearPathCache(); - clearParseCache(); - - return { id }; - } catch (err) { - logError('manifest', 'saveRequirementToDb failed', { fn: 'saveRequirementToDb', error: String((err as Error).message) }); - throw err; - } -} - -// ─── Save Decision to DB + Regenerate Markdown ──────────────────────────── - -export interface SaveDecisionFields { - scope: string; - decision: string; - choice: string; - rationale: string; - revisable?: string; - when_context?: string; - made_by?: import('./types.js').DecisionMadeBy; -} - -/** - * Save a new decision to DB and regenerate DECISIONS.md. - * Auto-assigns the next ID via nextDecisionId(). - * - * The ID computation (SELECT MAX) and insert are wrapped in a single - * transaction to prevent parallel tool calls from computing the same ID - * and silently overwriting each other (#3326, #3339, #3459). - * - * Returns the assigned ID. - */ -export async function saveDecisionToDb( - fields: SaveDecisionFields, - basePath: string, -): Promise<{ id: string }> { - try { - const db = await import('./gsd-db.js'); - - // Atomic ID assignment + insert inside a transaction to prevent - // parallel calls from racing on the same MAX(id) value. - const id = db.transaction(() => { - const adapter = db._getAdapter(); - if (!adapter) throw new GSDError(SF_STALE_STATE, "gsd-db: No database open"); - - const row = adapter - .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions') - .get(); - const maxNum = row ? (row['max_num'] as number | null) : null; - const nextId = (maxNum == null || isNaN(maxNum)) - ? 'D001' - : `D${String(maxNum + 1).padStart(3, '0')}`; - - db.upsertDecision({ - id: nextId, - when_context: fields.when_context ?? '', - scope: fields.scope, - decision: fields.decision, - choice: fields.choice, - rationale: fields.rationale, - revisable: fields.revisable ?? 'Yes', - made_by: fields.made_by ?? 'agent', - superseded_by: null, - }); - - return nextId; - }); - - // Fetch all decisions (including superseded for the full register) - const adapter = db._getAdapter(); - let allDecisions: Decision[] = []; - if (adapter) { - const rows = adapter.prepare('SELECT * FROM decisions ORDER BY seq').all(); - allDecisions = rows.map(row => ({ - seq: row['seq'] as number, - id: row['id'] as string, - when_context: row['when_context'] as string, - scope: row['scope'] as string, - decision: row['decision'] as string, - choice: row['choice'] as string, - rationale: row['rationale'] as string, - revisable: row['revisable'] as string, - made_by: (row['made_by'] as string as import('./types.js').DecisionMadeBy) ?? 'agent', - superseded_by: (row['superseded_by'] as string) ?? null, - })); - } - - const filePath = resolveGsdRootFile(basePath, 'DECISIONS'); - - // Check if existing DECISIONS.md has freeform (non-table) content. - // If so, preserve that content and append/update the decisions table - // at the end instead of overwriting the entire file. - let existingContent: string | null = null; - if (existsSync(filePath)) { - existingContent = readFileSync(filePath, 'utf-8'); - } - - let md: string; - if (existingContent && !isDecisionsTableFormat(existingContent)) { - // Freeform content detected — preserve it and append decisions table. - // Strip any previously appended decisions table section to avoid duplication. - const marker = '---\n\n## Decisions Table'; - const markerIdx = existingContent.indexOf(marker); - const freeformPart = markerIdx >= 0 - ? existingContent.substring(0, markerIdx).trimEnd() - : existingContent.trimEnd(); - md = freeformPart + '\n' + generateDecisionsAppendBlock(allDecisions); - } else { - // Table format or no existing file — full regeneration (original behavior) - md = generateDecisionsMd(allDecisions); - } - - try { - await saveFile(filePath, md); - } catch (diskErr) { - logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveDecisionToDb', error: String((diskErr as Error).message) }); - try { - db.deleteDecisionById(id); - } catch (rollbackErr) { - logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveDecisionToDb', id, error: String((rollbackErr as Error).message) }); - } - throw diskErr; - } - // #2661: When a decision defers a slice, update the slice status in the DB - // so the dispatcher skips it. Without this, STATE.md and DECISIONS.md are - // in split-brain: the decision says "deferred" but the state still says - // "active", causing auto-mode to keep dispatching the deferred work. - try { - const sliceRef = extractDeferredSliceRef(fields); - if (sliceRef) { - db.updateSliceStatus(sliceRef.milestoneId, sliceRef.sliceId, 'deferred'); - } - } catch (deferErr) { - // Non-fatal — log but don't fail the decision save - logError('manifest', 'failed to update deferred slice status', { - fn: 'saveDecisionToDb', - error: String((deferErr as Error).message), - }); - } - - // Invalidate file-read caches so deriveState() sees the updated markdown. - // Do NOT clear the artifacts table — we just wrote to it intentionally. - invalidateStateCache(); - clearPathCache(); - clearParseCache(); - - return { id }; - } catch (err) { - logError('manifest', 'saveDecisionToDb failed', { fn: 'saveDecisionToDb', error: String((err as Error).message) }); - throw err; - } -} - -/** - * Extract a milestone/slice reference from a deferral decision. - * - * Detects deferrals by checking: - * - scope contains "defer" (e.g., "deferral", "defer") - * - choice or decision contains "defer" + an M###/S## pattern - * - * Returns { milestoneId, sliceId } if found, null otherwise. - */ -export function extractDeferredSliceRef( - fields: Pick<SaveDecisionFields, 'scope' | 'decision' | 'choice'>, -): { milestoneId: string; sliceId: string } | null { - const isDeferral = - /\bdefer(?:ral|red|ring|s)?\b/i.test(fields.scope) || - /\bdefer(?:ral|red|ring|s)?\b/i.test(fields.choice) || - /\bdefer(?:ral|red|ring|s)?\b/i.test(fields.decision); - - if (!isDeferral) return null; - - // Look for M###/S## pattern in choice first, then decision - const slicePattern = /\b(M\d{3,4})\/(S\d{2,3})\b/; - const choiceMatch = fields.choice.match(slicePattern); - if (choiceMatch) { - return { milestoneId: choiceMatch[1], sliceId: choiceMatch[2] }; - } - const decisionMatch = fields.decision.match(slicePattern); - if (decisionMatch) { - return { milestoneId: decisionMatch[1], sliceId: decisionMatch[2] }; - } - - return null; -} - -// ─── Update Requirement in DB + Regenerate Markdown ─────────────────────── - -/** - * Update a requirement in DB and regenerate REQUIREMENTS.md. - * Fetches existing requirement, merges updates, upserts, then regenerates. - */ -export async function updateRequirementInDb( - id: string, - updates: Partial<Requirement>, - basePath: string, -): Promise<void> { - try { - const db = await import('./gsd-db.js'); - - let existing = db.getRequirementById(id); - - // If requirement doesn't exist in DB, seed the entire requirements table - // from REQUIREMENTS.md first (#3346). This handles the standard workflow - // where requirements are authored in markdown during discussion but never - // imported into the database — making gsd_requirement_update always fail - // with "not_found" at milestone completion. - if (!existing) { - const reqFilePath = resolveGsdRootFile(basePath, 'REQUIREMENTS'); - try { - const content = readFileSync(reqFilePath, 'utf-8'); - const { parseRequirementsSections } = await import('./md-importer.js'); - const parsed = parseRequirementsSections(content); - if (parsed.length > 0) { - logWarning('manifest', `Seeding ${parsed.length} requirements from REQUIREMENTS.md into DB (first update triggers import)`, { fn: 'updateRequirementInDb' }); - for (const req of parsed) { - // Only seed if not already in DB (avoid overwriting concurrent inserts) - if (!db.getRequirementById(req.id)) { - db.upsertRequirement(req); - } - } - // Re-check after seeding - existing = db.getRequirementById(id); - } - } catch { - // REQUIREMENTS.md missing or unparseable — fall through to skeleton - } - } - - const base: Requirement = existing ?? { - id, - class: '', - status: 'active', - description: '', - why: '', - source: '', - primary_owner: '', - supporting_slices: '', - validation: '', - notes: '', - full_content: '', - superseded_by: null, - }; - - // Merge updates into existing (or skeleton) - const merged: Requirement = { - ...base, - ...updates, - id: base.id, // ID cannot be changed - }; - - db.upsertRequirement(merged); - - // Fetch ALL requirements (including superseded) for full file regeneration - const adapter = db._getAdapter(); - let allRequirements: Requirement[] = []; - if (adapter) { - const rows = adapter.prepare('SELECT * FROM requirements ORDER BY id').all(); - allRequirements = rows.map(row => ({ - id: row['id'] as string, - class: row['class'] as string, - status: row['status'] as string, - description: row['description'] as string, - why: row['why'] as string, - source: row['source'] as string, - primary_owner: row['primary_owner'] as string, - supporting_slices: row['supporting_slices'] as string, - validation: row['validation'] as string, - notes: row['notes'] as string, - full_content: row['full_content'] as string, - superseded_by: (row['superseded_by'] as string) ?? null, - })); - } - - // Filter to non-superseded for the markdown file - // (superseded requirements don't appear in section headings) - const nonSuperseded = allRequirements.filter(r => r.superseded_by == null); - - const md = generateRequirementsMd(nonSuperseded); - const filePath = resolveGsdRootFile(basePath, 'REQUIREMENTS'); - try { - await saveFile(filePath, md); - } catch (diskErr) { - logError('manifest', 'disk write failed, reverting DB row', { fn: 'updateRequirementInDb', error: String((diskErr as Error).message) }); - if (existing) { - db.upsertRequirement(existing); - } - throw diskErr; - } - // Invalidate file-read caches so deriveState() sees the updated markdown. - // Do NOT clear the artifacts table — we just wrote to it intentionally. - invalidateStateCache(); - clearPathCache(); - clearParseCache(); - } catch (err) { - logError('manifest', 'updateRequirementInDb failed', { fn: 'updateRequirementInDb', error: String((err as Error).message) }); - throw err; - } -} - -// ─── Save Artifact to DB + Disk ─────────────────────────────────────────── - -export interface SaveArtifactOpts { - path: string; - artifact_type: string; - content: string; - milestone_id?: string; - slice_id?: string; - task_id?: string; -} - -/** - * Save an artifact to DB and write the corresponding markdown file to disk. - * The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md"). - * The full file path is computed as basePath + '.gsd/' + path. - */ -export async function saveArtifactToDb( - opts: SaveArtifactOpts, - basePath: string, -): Promise<void> { - try { - const db = await import('./gsd-db.js'); - - // Guard against path traversal before any reads/writes - const gsdDir = resolve(basePath, '.gsd'); - const fullPath = resolve(basePath, '.gsd', opts.path); - if (!fullPath.startsWith(gsdDir)) { - throw new GSDError(SF_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`); - } - - // Shrinkage guard: if the file already exists and the new content is - // significantly smaller (<50%), preserve the richer file on disk and - // store its content in the DB instead of the abbreviated version. - let dbContent = opts.content; - let skipDiskWrite = false; - if (existsSync(fullPath)) { - const existingSize = statSync(fullPath).size; - const newSize = Buffer.byteLength(opts.content, 'utf-8'); - if (existingSize > 0 && newSize < existingSize * 0.5) { - logWarning('manifest', `new content (${newSize}B) is <50% of existing file (${existingSize}B), preserving disk file`, { fn: 'saveArtifactToDb', path: opts.path }); - dbContent = readFileSync(fullPath, 'utf-8'); - skipDiskWrite = true; - } - } - - db.insertArtifact({ - path: opts.path, - artifact_type: opts.artifact_type, - milestone_id: opts.milestone_id ?? null, - slice_id: opts.slice_id ?? null, - task_id: opts.task_id ?? null, - full_content: dbContent, - }); - - // Write the file to disk (only if we're not preserving a richer existing file) - if (!skipDiskWrite) { - try { - await saveFile(fullPath, opts.content); - } catch (diskErr) { - logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveArtifactToDb', error: String((diskErr as Error).message) }); - db.deleteArtifactByPath(opts.path); - throw diskErr; - } - } - // Invalidate file-read caches so deriveState() sees the updated markdown. - // Do NOT clear the artifacts table — we just wrote to it intentionally. - invalidateStateCache(); - clearPathCache(); - clearParseCache(); - } catch (err) { - logError('manifest', 'saveArtifactToDb failed', { fn: 'saveArtifactToDb', error: String((err as Error).message) }); - throw err; - } -} diff --git a/src/resources/extensions/gsd/debug-logger.ts b/src/resources/extensions/gsd/debug-logger.ts deleted file mode 100644 index 4e29b633c..000000000 --- a/src/resources/extensions/gsd/debug-logger.ts +++ /dev/null @@ -1,178 +0,0 @@ -// SF Extension — Debug Logger -// Structured JSONL debug logging for diagnosing stuck/slow SF sessions. -// Zero overhead when disabled — all public functions are no-ops. - -import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; -import { gsdRoot } from './paths.js'; - -// ─── State ──────────────────────────────────────────────────────────────────── - -let _enabled = false; -let _logPath: string | null = null; -let _startTime = 0; - -/** Rolling counters for the debug summary written on stop. */ -const _counters = { - deriveStateCalls: 0, - deriveStateTotalMs: 0, - ttsrChecks: 0, - ttsrTotalMs: 0, - ttsrPeakBuffer: 0, - parseRoadmapCalls: 0, - parseRoadmapTotalMs: 0, - parsePlanCalls: 0, - parsePlanTotalMs: 0, - dispatches: 0, - renders: 0, -}; - -/** Max debug log files to keep. Older ones are pruned on enable. */ -const MAX_DEBUG_LOGS = 5; - -// ─── Public API ─────────────────────────────────────────────────────────────── - -/** - * Enable debug logging. Creates the log file and prunes old logs. - * Can be activated via `--debug` flag or `SF_DEBUG=1` env var. - */ -export function enableDebug(basePath: string): void { - const debugDir = join(gsdRoot(basePath), 'debug'); - mkdirSync(debugDir, { recursive: true }); - - // Prune old debug logs - try { - const files = readdirSync(debugDir) - .filter(f => f.startsWith('debug-') && f.endsWith('.log')) - .sort(); - while (files.length >= MAX_DEBUG_LOGS) { - const oldest = files.shift()!; - try { unlinkSync(join(debugDir, oldest)); } catch { /* ignore */ } - } - } catch { /* non-fatal */ } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - _logPath = join(debugDir, `debug-${timestamp}.log`); - _startTime = Date.now(); - _enabled = true; - - // Reset counters - for (const key of Object.keys(_counters) as (keyof typeof _counters)[]) { - _counters[key] = 0; - } -} - -/** Disable debug logging and return the log file path (if any). */ -export function disableDebug(): string | null { - const path = _logPath; - _enabled = false; - _logPath = null; - _startTime = 0; - return path; -} - -/** Check if debug mode is active. */ -export function isDebugEnabled(): boolean { - return _enabled; -} - -/** Return the current log file path (or null). */ -export function getDebugLogPath(): string | null { - return _logPath; -} - -/** - * Log a structured debug event. No-op when debug is disabled. - * - * Each event is one JSON line: `{ ts, event, ...data }` - */ -export function debugLog(event: string, data?: Record<string, unknown>): void { - if (!_enabled || !_logPath) return; - - const entry = { - ts: new Date().toISOString(), - event, - ...data, - }; - - try { - appendFileSync(_logPath, JSON.stringify(entry) + '\n'); - } catch { - // Silently ignore write failures — debug logging must never break SF - } -} - -/** - * Start a timer for a named operation. Returns a stop function that logs - * the elapsed time and optional result data. - * - * Usage: - * ```ts - * const stop = debugTime('derive-state'); - * const result = await deriveState(base); - * stop({ phase: result.phase }); - * ``` - */ -export function debugTime(event: string): (data?: Record<string, unknown>) => void { - if (!_enabled) return _noop; - - const start = performance.now(); - return (data?: Record<string, unknown>) => { - const elapsed_ms = Math.round((performance.now() - start) * 100) / 100; - debugLog(event, { elapsed_ms, ...data }); - }; -} - -// ─── Counter Helpers ────────────────────────────────────────────────────────── - -/** Increment a debug counter (used by instrumentation points). */ -export function debugCount(counter: keyof typeof _counters, value = 1): void { - if (!_enabled) return; - _counters[counter] += value; -} - -/** Record a peak value (only updates if new value is higher). */ -export function debugPeak(counter: keyof typeof _counters, value: number): void { - if (!_enabled) return; - if (value > _counters[counter]) { - _counters[counter] = value; - } -} - -/** - * Write the debug summary and disable logging. Call this when auto-mode stops. - * Returns the log file path for user notification. - */ -export function writeDebugSummary(): string | null { - if (!_enabled || !_logPath) return null; - - const totalElapsed_ms = Date.now() - _startTime; - const avgDeriveState_ms = _counters.deriveStateCalls > 0 - ? Math.round((_counters.deriveStateTotalMs / _counters.deriveStateCalls) * 100) / 100 - : 0; - const avgTtsrCheck_ms = _counters.ttsrChecks > 0 - ? Math.round((_counters.ttsrTotalMs / _counters.ttsrChecks) * 100) / 100 - : 0; - - debugLog('debug-summary', { - totalElapsed_ms, - dispatches: _counters.dispatches, - deriveStateCalls: _counters.deriveStateCalls, - avgDeriveState_ms, - parseRoadmapCalls: _counters.parseRoadmapCalls, - avgParseRoadmap_ms: _counters.parseRoadmapCalls > 0 - ? Math.round((_counters.parseRoadmapTotalMs / _counters.parseRoadmapCalls) * 100) / 100 - : 0, - parsePlanCalls: _counters.parsePlanCalls, - ttsrChecks: _counters.ttsrChecks, - avgTtsrCheck_ms, - ttsrPeakBuffer: _counters.ttsrPeakBuffer, - renders: _counters.renders, - }); - - return disableDebug(); -} - -// ─── Internal ───────────────────────────────────────────────────────────────── - -function _noop(_data?: Record<string, unknown>): void { /* no-op */ } diff --git a/src/resources/extensions/gsd/definition-io.ts b/src/resources/extensions/gsd/definition-io.ts deleted file mode 100644 index ac0ed9a42..000000000 --- a/src/resources/extensions/gsd/definition-io.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * definition-io.ts — Read frozen DEFINITION.yaml from a run directory. - * - * Extracted from custom-workflow-engine.ts to break the circular dependency - * between context-injector.ts and custom-workflow-engine.ts. - */ - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { parse } from "yaml"; -import type { WorkflowDefinition } from "./definition-loader.js"; - -/** Read and parse the frozen DEFINITION.yaml from a run directory. */ -export function readFrozenDefinition(runDir: string): WorkflowDefinition { - const defPath = join(runDir, "DEFINITION.yaml"); - const raw = readFileSync(defPath, "utf-8"); - return parse(raw, { schema: "core" }) as WorkflowDefinition; -} diff --git a/src/resources/extensions/gsd/definition-loader.ts b/src/resources/extensions/gsd/definition-loader.ts deleted file mode 100644 index a3cce2528..000000000 --- a/src/resources/extensions/gsd/definition-loader.ts +++ /dev/null @@ -1,462 +0,0 @@ -/** - * definition-loader.ts — Parse and validate V1 YAML workflow definitions. - * - * Loads definition YAML files from `.gsd/workflow-defs/`, validates the - * V1 schema shape, and returns typed TypeScript objects. Pure functions - * with no engine or runtime dependencies — just `yaml` and `node:fs`. - * - * YAML uses snake_case (`depends_on`, `context_from`) per project convention (P005). - * TypeScript uses camelCase (`dependsOn`, `contextFrom`). - * - * Observability: All validation errors are collected into a string[] — callers - * can log, surface in dashboards, or return to agents for self-repair. - * substituteParams errors include the offending key name for traceability. - */ - -import { parse } from "yaml"; -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -// ─── Public TypeScript Types (camelCase) ───────────────────────────────── - -export type VerifyPolicy = - | { policy: "content-heuristic"; minSize?: number; pattern?: string } - | { policy: "shell-command"; command: string } - | { policy: "prompt-verify"; prompt: string } - | { policy: "human-review" }; - -export interface IterateConfig { - /** Artifact path (relative to run dir) to read and match against. */ - source: string; - /** Regex pattern string. Must contain at least one capture group. Applied with global flag. */ - pattern: string; -} - -export interface StepDefinition { - /** Unique step identifier within the workflow. */ - id: string; - /** Human-readable step name. */ - name: string; - /** The prompt to dispatch for this step. */ - prompt: string; - /** IDs of steps that must complete before this step can run. */ - requires: string[]; - /** Artifact paths produced by this step (relative to run dir). */ - produces: string[]; - /** Step IDs whose artifacts to include as context (S05 — accepted, not processed). */ - contextFrom?: string[]; - /** Verification policy for this step (S05 — typed + validated). */ - verify?: VerifyPolicy; - /** Iteration config for this step (S06 — typed + validated). */ - iterate?: IterateConfig; -} - -export interface WorkflowDefinition { - /** Schema version — must be 1. */ - version: number; - /** Workflow name. */ - name: string; - /** Optional description. */ - description?: string; - /** Optional parameter map for template substitution (S07). */ - params?: Record<string, string>; - /** Ordered list of steps. */ - steps: StepDefinition[]; -} - -// ─── Internal YAML Types (snake_case) ──────────────────────────────────── - -interface YamlStepDef { - id?: unknown; - name?: unknown; - prompt?: unknown; - requires?: unknown; - depends_on?: unknown; - produces?: unknown; - context_from?: unknown; - verify?: unknown; - iterate?: unknown; - [key: string]: unknown; // Forward-compat: unknown fields accepted silently -} - -interface YamlWorkflowDef { - version?: unknown; - name?: unknown; - description?: unknown; - params?: unknown; - steps?: unknown; - [key: string]: unknown; // Forward-compat: unknown fields accepted silently -} - -// ─── Validation ────────────────────────────────────────────────────────── - -/** - * Validate a parsed (but untyped) YAML object against the V1 workflow schema. - * - * Collects all errors (does not short-circuit) so a single call reveals - * every problem with the definition. - * - * Unknown fields are silently accepted for forward compatibility with - * S05/S06 features (`context_from`, `verify`, `iterate`). - */ -export function validateDefinition(parsed: unknown): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (parsed == null || typeof parsed !== "object") { - return { valid: false, errors: ["Definition must be a non-null object"] }; - } - - const def = parsed as YamlWorkflowDef; - - // version: must be 1 (number) - if (def.version === undefined || def.version === null) { - errors.push("Missing required field: version"); - } else if (def.version !== 1) { - errors.push(`Unsupported version: ${def.version} (expected 1)`); - } - - // name: must be a non-empty string - if (typeof def.name !== "string" || def.name.trim() === "") { - errors.push("Missing or empty required field: name"); - } - - // steps: must be a non-empty array - if (!Array.isArray(def.steps)) { - errors.push("Missing required field: steps (must be an array)"); - } else if (def.steps.length === 0) { - errors.push("steps must contain at least one step"); - } else { - // Track whether all steps have valid IDs — graph-level checks only run when true - let allStepIdsValid = true; - - for (let i = 0; i < def.steps.length; i++) { - const step = def.steps[i] as YamlStepDef; - if (step == null || typeof step !== "object") { - errors.push(`Step at index ${i} is not an object`); - allStepIdsValid = false; - continue; - } - - // Required step fields - if (typeof step.id !== "string" || step.id.trim() === "") { - errors.push(`Step at index ${i} missing required field: id`); - allStepIdsValid = false; - } - if (typeof step.name !== "string" || step.name.trim() === "") { - errors.push(`Step at index ${i} missing required field: name`); - } - if (typeof step.prompt !== "string" || step.prompt.trim() === "") { - errors.push(`Step at index ${i} missing required field: prompt`); - } - - // produces: path traversal guard - if (Array.isArray(step.produces)) { - for (const p of step.produces) { - if (typeof p === "string" && p.includes("..")) { - errors.push(`Step "${step.id}" produces path contains disallowed '..': ${p}`); - } - } - } - - // iterate: optional, but if present must conform to IterateConfig shape - if (step.iterate !== undefined) { - const it = step.iterate; - const sid = typeof step.id === "string" ? step.id : `index ${i}`; - if (it == null || typeof it !== "object" || Array.isArray(it)) { - errors.push(`Step "${sid}" iterate must be an object with "source" and "pattern" fields`); - } else { - const itObj = it as Record<string, unknown>; - if (typeof itObj.source !== "string" || (itObj.source as string).trim() === "") { - errors.push(`Step "${sid}" iterate.source must be a non-empty string`); - } else if ((itObj.source as string).includes("..")) { - errors.push(`Step "${sid}" iterate.source contains disallowed '..' path traversal`); - } - if (typeof itObj.pattern !== "string" || (itObj.pattern as string).trim() === "") { - errors.push(`Step "${sid}" iterate.pattern must be a non-empty string`); - } else { - const pat = itObj.pattern as string; - let regexValid = true; - try { - new RegExp(pat); - } catch { - regexValid = false; - errors.push(`Step "${sid}" iterate.pattern is not a valid regex: ${pat}`); - } - if (regexValid && !/\((?!\?)/.test(pat)) { - errors.push(`Step "${sid}" iterate.pattern must contain at least one capture group`); - } - } - } - } - - // verify: optional, but if present must conform to VerifyPolicy shape - if (step.verify !== undefined) { - const v = step.verify; - const sid = typeof step.id === "string" ? step.id : `index ${i}`; - if (v == null || typeof v !== "object" || Array.isArray(v)) { - errors.push(`Step "${sid}" verify must be an object with a "policy" field`); - } else { - const vObj = v as Record<string, unknown>; - const VALID_POLICIES = ["content-heuristic", "shell-command", "prompt-verify", "human-review"]; - if (typeof vObj.policy !== "string" || !VALID_POLICIES.includes(vObj.policy)) { - errors.push(`Step "${sid}" verify.policy must be one of: ${VALID_POLICIES.join(", ")}`); - } else { - // Policy-specific required field checks - if (vObj.policy === "shell-command") { - if (typeof vObj.command !== "string" || (vObj.command as string).trim() === "") { - errors.push(`Step "${sid}" verify policy "shell-command" requires a non-empty "command" field`); - } - } - if (vObj.policy === "prompt-verify") { - if (typeof vObj.prompt !== "string" || (vObj.prompt as string).trim() === "") { - errors.push(`Step "${sid}" verify policy "prompt-verify" requires a non-empty "prompt" field`); - } - } - } - } - } - } - - // ─── Graph-level validations (only when all step IDs are valid) ──── - if (allStepIdsValid) { - const steps = def.steps as YamlStepDef[]; - - // 1. Duplicate step ID check - const idCounts = new Map<string, number>(); - for (const step of steps) { - const id = step.id as string; - idCounts.set(id, (idCounts.get(id) ?? 0) + 1); - } - for (const [id, count] of idCounts) { - if (count > 1) { - errors.push(`Duplicate step id: ${id}`); - } - } - - // Build valid ID set for remaining checks - const validIds = new Set(steps.map((s) => s.id as string)); - - // 2. Dangling dependency check + 3. Self-referencing dependency check - for (const step of steps) { - const sid = step.id as string; - const deps = Array.isArray(step.requires) - ? (step.requires as string[]) - : Array.isArray(step.depends_on) - ? (step.depends_on as string[]) - : []; - - for (const depId of deps) { - if (depId === sid) { - errors.push(`Step '${sid}' depends on itself`); - } else if (!validIds.has(depId)) { - errors.push(`Step '${sid}' requires unknown step '${depId}'`); - } - } - } - - // 4. Cycle detection (DFS) — only when no duplicate IDs - if (![...idCounts.values()].some((c: number) => c > 1)) { - // Build adjacency list: step → its dependencies - const adj = new Map<string, string[]>(); - for (const step of steps) { - const sid = step.id as string; - const deps = Array.isArray(step.requires) - ? (step.requires as string[]) - : Array.isArray(step.depends_on) - ? (step.depends_on as string[]) - : []; - adj.set(sid, deps.filter((d) => validIds.has(d) && d !== sid)); - } - - const WHITE = 0, GRAY = 1, BLACK = 2; - const color = new Map<string, number>(); - for (const id of validIds) color.set(id, WHITE); - - const parent = new Map<string, string | null>(); - - function dfs(node: string): string[] | null { - color.set(node, GRAY); - for (const dep of adj.get(node) ?? []) { - if (color.get(dep) === GRAY) { - // Back edge found — reconstruct cycle path - const cycle: string[] = [dep, node]; - let cur = node; - while (parent.has(cur) && parent.get(cur) !== null && parent.get(cur) !== dep) { - cur = parent.get(cur)!; - cycle.push(cur); - } - cycle.push(dep); - cycle.reverse(); - return cycle; - } - if (color.get(dep) === WHITE) { - parent.set(dep, node); - const result = dfs(dep); - if (result) return result; - } - } - color.set(node, BLACK); - return null; - } - - for (const id of validIds) { - if (color.get(id) === WHITE) { - parent.set(id, null); - const cycle = dfs(id); - if (cycle) { - errors.push(`Cycle detected: ${cycle.join(" → ")}`); - break; // One cycle error is enough - } - } - } - } - } - } - - return { valid: errors.length === 0, errors }; -} - -// ─── Loading ───────────────────────────────────────────────────────────── - -/** - * Load and validate a YAML workflow definition from the filesystem. - * - * Reads `<defsDir>/<name>.yaml`, parses YAML, validates the V1 schema, - * and converts snake_case YAML keys to camelCase TypeScript types. - * - * @param defsDir — directory containing definition YAML files - * @param name — definition filename without extension - * @returns Parsed and validated WorkflowDefinition - * @throws Error if file is missing, YAML is malformed, or schema is invalid - */ -export function loadDefinition(defsDir: string, name: string): WorkflowDefinition { - const filePath = join(defsDir, `${name}.yaml`); - - if (!existsSync(filePath)) { - throw new Error(`Definition file not found: ${filePath}`); - } - - const raw = readFileSync(filePath, "utf-8"); - let parsed: unknown; - try { - parsed = parse(raw); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(`Failed to parse YAML in ${filePath}: ${msg}`); - } - - const { valid, errors } = validateDefinition(parsed); - if (!valid) { - throw new Error(`Invalid workflow definition in ${filePath}:\n - ${errors.join("\n - ")}`); - } - - // Convert snake_case YAML → camelCase TypeScript - const yamlDef = parsed as YamlWorkflowDef; - const yamlSteps = yamlDef.steps as YamlStepDef[]; - - return { - version: yamlDef.version as number, - name: yamlDef.name as string, - description: typeof yamlDef.description === "string" ? yamlDef.description : undefined, - params: yamlDef.params != null && typeof yamlDef.params === "object" - ? Object.fromEntries( - Object.entries(yamlDef.params as Record<string, unknown>).map( - ([k, v]) => [k, String(v)], - ), - ) - : undefined, - steps: yamlSteps.map((s) => ({ - id: s.id as string, - name: s.name as string, - prompt: s.prompt as string, - requires: Array.isArray(s.requires) - ? (s.requires as string[]) - : Array.isArray(s.depends_on) - ? (s.depends_on as string[]) - : [], - produces: Array.isArray(s.produces) ? (s.produces as string[]) : [], - contextFrom: Array.isArray(s.context_from) ? (s.context_from as string[]) : undefined, - verify: s.verify as VerifyPolicy | undefined, - iterate: (s.iterate != null && typeof s.iterate === "object") - ? s.iterate as IterateConfig - : undefined, - })), - }; -} - -// ─── Parameter Substitution ────────────────────────────────────────────── - -/** Regex matching `{{key}}` placeholders — captures the key name. */ -const PARAM_PATTERN = /\{\{(\w+)\}\}/g; - -/** - * Replace `{{key}}` placeholders in a single prompt string. - * - * Exported for use by the engine on iteration-instance prompts that live - * in GRAPH.yaml (outside the definition's step list). - * - * @throws Error if any merged param value contains `..` (path-traversal guard) - */ -export function substitutePromptString( - prompt: string, - merged: Record<string, string>, -): string { - return prompt.replace(PARAM_PATTERN, (match, key: string) => { - const value = merged[key]; - return value !== undefined ? value : match; - }); -} - -/** - * Replace `{{key}}` placeholders in all step prompts with param values. - * - * Merge order: `definition.params` (defaults) ← `overrides` (CLI wins). - * Returns a **new** WorkflowDefinition — the input is never mutated. - * - * @throws Error if any param value contains `..` (path-traversal guard) - * @throws Error if any `{{key}}` remains unresolved after substitution - */ -export function substituteParams( - definition: WorkflowDefinition, - overrides?: Record<string, string>, -): WorkflowDefinition { - const merged: Record<string, string> = { - ...(definition.params ?? {}), - ...(overrides ?? {}), - }; - - // Path-traversal guard: reject any value containing ".." - for (const [key, value] of Object.entries(merged)) { - if (value.includes("..")) { - throw new Error( - `Parameter "${key}" contains disallowed '..' (path traversal): ${value}`, - ); - } - } - - // Substitute in each step prompt - const substitutedSteps = definition.steps.map((step) => ({ - ...step, - prompt: substitutePromptString(step.prompt, merged), - })); - - // Check for unresolved placeholders - const unresolved = new Set<string>(); - for (const step of substitutedSteps) { - let m: RegExpExecArray | null; - const re = new RegExp(PARAM_PATTERN.source, "g"); - while ((m = re.exec(step.prompt)) !== null) { - unresolved.add(m[1]); - } - } - - if (unresolved.size > 0) { - const keys = [...unresolved].sort().join(", "); - throw new Error(`Unresolved parameter(s) in step prompts: ${keys}`); - } - - return { - ...definition, - steps: substitutedSteps, - }; -} diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts deleted file mode 100644 index 535b553b6..000000000 --- a/src/resources/extensions/gsd/detection.ts +++ /dev/null @@ -1,1154 +0,0 @@ -/** - * SF Detection — Project state and ecosystem detection. - * - * Pure functions, zero UI dependencies, zero side effects. - * Used by init-wizard.ts and guided-flow.ts to determine what onboarding - * flow to show when entering a project directory. - */ - -import { existsSync, openSync, readSync, closeSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { gsdRoot } from "./paths.js"; - -const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); - -// ─── Types ────────────────────────────────────────────────────────────────────── - -export interface ProjectDetection { - /** What kind of SF state exists in this directory */ - state: "none" | "v1-planning" | "v2-gsd" | "v2-gsd-empty"; - - /** Is this the first time SF has been used on this machine? */ - isFirstEverLaunch: boolean; - - /** Does ~/.gsd/ exist with preferences? */ - hasGlobalSetup: boolean; - - /** v1 details (only when state === 'v1-planning') */ - v1?: V1Detection; - - /** v2 details (only when state === 'v2-gsd' or 'v2-gsd-empty') */ - v2?: V2Detection; - - /** Detected project ecosystem signals */ - projectSignals: ProjectSignals; -} - -export interface V1Detection { - path: string; - hasPhasesDir: boolean; - hasRoadmap: boolean; - phaseCount: number; -} - -export interface V2Detection { - milestoneCount: number; - hasPreferences: boolean; - hasContext: boolean; -} - -/** Apple platform SDKROOTs found in Xcode project.pbxproj files. */ -export type XcodePlatform = "iphoneos" | "macosx" | "watchos" | "appletvos" | "xros"; - -export interface ProjectSignals { - /** Detected project/package files */ - detectedFiles: string[]; - /** Is this already a git repo? */ - isGitRepo: boolean; - /** Is this a monorepo? */ - isMonorepo: boolean; - /** Primary language hint */ - primaryLanguage?: string; - /** Apple platform SDKROOTs detected from *.xcodeproj/project.pbxproj */ - xcodePlatforms: XcodePlatform[]; - /** Has existing CI configuration? */ - hasCI: boolean; - /** Has existing test setup? */ - hasTests: boolean; - /** Detected package manager */ - packageManager?: string; - /** Auto-detected verification commands */ - verificationCommands: string[]; -} - -// ─── Project File Markers ─────────────────────────────────────────────────────── - -export const PROJECT_FILES = [ - "package.json", - "Cargo.toml", - "go.mod", - "pyproject.toml", - "setup.py", - "Gemfile", - "pom.xml", - "build.gradle", - "build.gradle.kts", - "CMakeLists.txt", - "Makefile", - "composer.json", - "pubspec.yaml", - "Package.swift", - "mix.exs", - "deno.json", - "deno.jsonc", - // .NET - ".sln", - ".csproj", - "Directory.Build.props", - // Git submodules - ".gitmodules", - // Xcode - "project.yml", - ".xcodeproj", - ".xcworkspace", - // Cloud platform config files - "firebase.json", - "cdk.json", - "samconfig.toml", - "serverless.yml", - "serverless.yaml", - "azure-pipelines.yml", - // Database / ORM config files - "prisma/schema.prisma", - "supabase/config.toml", - "drizzle.config.ts", - "drizzle.config.js", - "redis.conf", - // React Native markers - "metro.config.js", - "metro.config.ts", - "react-native.config.js", - // Frontend framework config files - "angular.json", - "next.config.js", - "next.config.ts", - "next.config.mjs", - "nuxt.config.ts", - "nuxt.config.js", - "svelte.config.js", - "svelte.config.ts", - // Vue CLI config files - "vue.config.js", - "vue.config.ts", - // Frontend tooling - "tailwind.config.js", - "tailwind.config.ts", - "tailwind.config.mjs", - "tailwind.config.cjs", - // Android project markers - "app/build.gradle", - "app/build.gradle.kts", - // Container / DevOps config files - "Dockerfile", - "docker-compose.yml", - "docker-compose.yaml", - // Infrastructure as Code - "main.tf", - // Kubernetes / Helm markers - "Chart.yaml", - "kustomization.yaml", - // CI/CD markers - ".github/workflows", - // Blockchain / Web3 markers - "hardhat.config.js", - "hardhat.config.ts", - "foundry.toml", - // Data engineering markers - "dbt_project.yml", - "airflow.cfg", - // Game engine markers - "ProjectSettings/ProjectVersion.txt", - "project.godot", - // Python framework markers - "manage.py", - "requirements.txt", -] as const; - -/** File extensions that indicate SQLite databases in the project. */ -const SQLITE_EXTENSIONS = [".sqlite", ".sqlite3", ".db"] as const; - -/** File extensions that indicate SQL usage (migrations, schemas, seeds). */ -const SQL_EXTENSIONS = [".sql"] as const; - -/** File extensions that indicate .NET / C# projects. */ -const DOTNET_EXTENSIONS = [".csproj", ".sln", ".fsproj"] as const; - -/** File extensions that indicate Vue.js single-file components. */ -const VUE_EXTENSIONS = [".vue"] as const; - -const LANGUAGE_MAP: Record<string, string> = { - "package.json": "javascript/typescript", - "Cargo.toml": "rust", - "go.mod": "go", - "pyproject.toml": "python", - "setup.py": "python", - "Gemfile": "ruby", - "pom.xml": "java", - "build.gradle": "java/kotlin", - "build.gradle.kts": "kotlin", - "app/build.gradle": "java/kotlin", - "app/build.gradle.kts": "kotlin", - "CMakeLists.txt": "c/c++", - "composer.json": "php", - "pubspec.yaml": "dart/flutter", - "Package.swift": "swift", - "mix.exs": "elixir", - "deno.json": "typescript/deno", - "deno.jsonc": "typescript/deno", - ".sln": "dotnet", - ".csproj": "dotnet", - "Directory.Build.props": "dotnet", - "project.yml": "swift/xcode", - ".xcodeproj": "swift/xcode", - ".xcworkspace": "swift/xcode", - "Dockerfile": "docker", - "manage.py": "python", - "requirements.txt": "python", -}; - -const MONOREPO_MARKERS = [ - "lerna.json", - "nx.json", - "turbo.json", - "pnpm-workspace.yaml", -] as const; - -const CI_MARKERS = [ - ".github/workflows", - ".gitlab-ci.yml", - "Jenkinsfile", - ".circleci", - ".travis.yml", - "azure-pipelines.yml", - "bitbucket-pipelines.yml", -] as const; - -const TEST_MARKERS = [ - "__tests__", - "tests", - "test", - "spec", - "jest.config.js", - "jest.config.ts", - "vitest.config.ts", - "vitest.config.js", - ".mocharc.yml", - "pytest.ini", - "conftest.py", - "phpunit.xml", -] as const; - -/** Directories skipped during bounded recursive project scans. */ -const RECURSIVE_SCAN_IGNORED_DIRS = new Set([ - ".git", - ".gsd", - ".planning", - ".plans", - ".claude", - ".cursor", - ".vscode", - "node_modules", - ".venv", - "venv", - "dist", - "build", - "coverage", - ".next", - ".nuxt", - "target", - "vendor", - ".turbo", - "Pods", - "bin", - "obj", - ".gradle", - "DerivedData", - "out", -]) as ReadonlySet<string>; - -/** Project file markers safe to detect recursively via suffix matching. */ -const ROOT_ONLY_PROJECT_FILES = new Set<string>([ - ".github/workflows", - "package.json", - "Gemfile", - "Makefile", - "CMakeLists.txt", - "build.gradle", - "build.gradle.kts", - "deno.json", - "deno.jsonc", -]); - -const MAX_RECURSIVE_SCAN_FILES = 2000; -const MAX_RECURSIVE_SCAN_DEPTH = 6; - -// ─── Core Detection ───────────────────────────────────────────────────────────── - -/** - * Detect the full project state for a given directory. - * This is the main entry point — calls all sub-detectors. - */ -export function detectProjectState(basePath: string): ProjectDetection { - const v1 = detectV1Planning(basePath); - const v2 = detectV2Gsd(basePath); - const projectSignals = detectProjectSignals(basePath); - const globalSetup = hasGlobalSetup(); - const firstEver = isFirstEverLaunch(); - - let state: ProjectDetection["state"]; - if (v2 && v2.milestoneCount > 0) { - state = "v2-gsd"; - } else if (v2 && v2.milestoneCount === 0) { - state = "v2-gsd-empty"; - } else if (v1) { - state = "v1-planning"; - } else { - state = "none"; - } - - return { - state, - isFirstEverLaunch: firstEver, - hasGlobalSetup: globalSetup, - v1: v1 ?? undefined, - v2: v2 ?? undefined, - projectSignals, - }; -} - -// ─── V1 Planning Detection ────────────────────────────────────────────────────── - -/** - * Detect a v1 .planning/ directory with SF v1 markers. - * Returns null if no .planning/ directory found. - */ -export function detectV1Planning(basePath: string): V1Detection | null { - const planningPath = join(basePath, ".planning"); - - if (!existsSync(planningPath)) return null; - - try { - const stat = statSync(planningPath); - if (!stat.isDirectory()) return null; - } catch { - return null; - } - - const hasRoadmap = existsSync(join(planningPath, "ROADMAP.md")); - const phasesPath = join(planningPath, "phases"); - const hasPhasesDir = existsSync(phasesPath); - - let phaseCount = 0; - if (hasPhasesDir) { - try { - const entries = readdirSync(phasesPath, { withFileTypes: true }); - phaseCount = entries.filter(e => e.isDirectory()).length; - } catch { - // unreadable — report 0 - } - } - - return { - path: planningPath, - hasPhasesDir, - hasRoadmap, - phaseCount, - }; -} - -// ─── V2 SF Detection ────────────────────────────────────────────────────────── - -function detectV2Gsd(basePath: string): V2Detection | null { - const gsdPath = gsdRoot(basePath); - - if (!existsSync(gsdPath)) return null; - - const hasPreferences = - existsSync(join(gsdPath, "PREFERENCES.md")) || - existsSync(join(gsdPath, "preferences.md")); - - const hasContext = existsSync(join(gsdPath, "CONTEXT.md")); - - let milestoneCount = 0; - const milestonesPath = join(gsdPath, "milestones"); - if (existsSync(milestonesPath)) { - try { - const entries = readdirSync(milestonesPath, { withFileTypes: true }); - milestoneCount = entries.filter(e => e.isDirectory()).length; - } catch { - // unreadable — report 0 - } - } - - return { milestoneCount, hasPreferences, hasContext }; -} - -// ─── Project Signals Detection ────────────────────────────────────────────────── - -/** - * Quick filesystem scan for project ecosystem markers. - * Reads only file existence + minimal content (package.json for monorepo/scripts). - */ -export function detectProjectSignals(basePath: string): ProjectSignals { - const detectedFiles: string[] = []; - let primaryLanguage: string | undefined; - - // Detect project files - for (const file of PROJECT_FILES) { - if (existsSync(join(basePath, file))) { - detectedFiles.push(file); - if (!primaryLanguage) { - primaryLanguage = LANGUAGE_MAP[file]; - } - } - } - - // Bounded recursive scan for nested markers and dependency files. - // This covers common brownfield layouts like src/App/App.csproj, - // db/migrations/*.sql, src/components/*.vue, and services/api/pyproject.toml - // without walking the entire repo or diving into heavyweight folders. - const scannedFiles = scanProjectFiles(basePath); - - for (const file of PROJECT_FILES) { - if (detectedFiles.includes(file) || ROOT_ONLY_PROJECT_FILES.has(file)) continue; - const hasMatch = file === "requirements.txt" - ? scannedFiles.some(isPythonRequirementsFile) - : scannedFiles.some((scannedFile) => matchesProjectFileMarker(scannedFile, file)); - if (hasMatch) { - pushUnique(detectedFiles, file); - if (!primaryLanguage && LANGUAGE_MAP[file]) { - primaryLanguage = LANGUAGE_MAP[file]; - } - } - } - - if (scannedFiles.some((file) => SQLITE_EXTENSIONS.some((ext) => file.endsWith(ext)))) { - pushUnique(detectedFiles, "*.sqlite"); - } - if (scannedFiles.some((file) => SQL_EXTENSIONS.some((ext) => file.endsWith(ext)))) { - pushUnique(detectedFiles, "*.sql"); - } - - const hasCsproj = scannedFiles.some((file) => file.endsWith(".csproj")); - const hasFsproj = scannedFiles.some((file) => file.endsWith(".fsproj")); - const hasSln = scannedFiles.some((file) => file.endsWith(".sln")); - - if (hasCsproj) { - pushUnique(detectedFiles, "*.csproj"); - if (!primaryLanguage) primaryLanguage = "csharp"; - } - if (hasFsproj) { - pushUnique(detectedFiles, "*.fsproj"); - if (!primaryLanguage) primaryLanguage = "fsharp"; - } - if (hasSln) { - pushUnique(detectedFiles, "*.sln"); - if (!primaryLanguage) primaryLanguage = "dotnet"; - } - - if (scannedFiles.some((file) => VUE_EXTENSIONS.some((ext) => file.endsWith(ext)))) { - pushUnique(detectedFiles, "*.vue"); - } - - // Python framework detection — scan dependency files for framework-specific packages. - // Adds synthetic markers (e.g. "dep:fastapi") so skill catalog matchFiles can reference them. - const dependencyFiles = scannedFiles.filter((file) => - isPythonRequirementsFile(file) || file.endsWith("pyproject.toml"), - ); - if (containsFastapiDependency(basePath, dependencyFiles)) { - pushUnique(detectedFiles, "dep:fastapi"); - } - - const springBootBuildFiles = scannedFiles.filter((file) => - file.endsWith("pom.xml") || file.endsWith("build.gradle") || file.endsWith("build.gradle.kts"), - ); - const springBootVersionCatalogs = scannedFiles.filter((file) => file.endsWith(".versions.toml")); - const springBootSettingsFiles = scannedFiles.filter((file) => - file.endsWith("settings.gradle") || file.endsWith("settings.gradle.kts"), - ); - if (containsSpringBootMarker(basePath, springBootBuildFiles, springBootVersionCatalogs, springBootSettingsFiles)) { - pushUnique(detectedFiles, "dep:spring-boot"); - if (!primaryLanguage) { - primaryLanguage = "java/kotlin"; - } - } - - // Git repo detection - const isGitRepo = existsSync(join(basePath, ".git")); - - // Xcode platform detection — parse SDKROOT from project.pbxproj - const xcodePlatforms = detectXcodePlatforms(basePath); - - // Set primaryLanguage to swift when an Xcode project is found but no - // Package.swift was detected (CocoaPods or SPM-less projects). - if (!primaryLanguage && xcodePlatforms.length > 0) { - primaryLanguage = "swift"; - } - - // Monorepo detection - let isMonorepo = false; - for (const marker of MONOREPO_MARKERS) { - if (existsSync(join(basePath, marker))) { - isMonorepo = true; - break; - } - } - // Also check package.json workspaces - if (!isMonorepo && detectedFiles.includes("package.json")) { - isMonorepo = packageJsonHasWorkspaces(basePath); - } - - // CI detection - let hasCI = false; - for (const marker of CI_MARKERS) { - if (existsSync(join(basePath, marker))) { - hasCI = true; - break; - } - } - - // Test detection - let hasTests = false; - for (const marker of TEST_MARKERS) { - if (existsSync(join(basePath, marker))) { - hasTests = true; - break; - } - } - - // Package manager detection - const packageManager = detectPackageManager(basePath); - - // Verification commands - const verificationCommands = detectVerificationCommands(basePath, detectedFiles, packageManager); - - return { - detectedFiles, - isGitRepo, - isMonorepo, - primaryLanguage, - xcodePlatforms, - hasCI, - hasTests, - packageManager, - verificationCommands, - }; -} - -// ─── Xcode Platform Detection ─────────────────────────────────────────────────── - -/** Known SDKROOT values → canonical platform names. */ -const SDKROOT_MAP: Record<string, XcodePlatform> = { - iphoneos: "iphoneos", - iphonesimulator: "iphoneos", // simulator builds still target iOS - macosx: "macosx", - watchos: "watchos", - watchsimulator: "watchos", - appletvos: "appletvos", - appletvsimulator: "appletvos", - xros: "xros", - xrsimulator: "xros", -}; - -/** Regex for SUPPORTED_PLATFORMS — fallback when SDKROOT = auto (Xcode 15+). */ -const SUPPORTED_PLATFORMS_RE = /SUPPORTED_PLATFORMS\s*=\s*"([^"]+)"/gi; - -/** Read at most `maxBytes` from a file without loading the full file into memory. */ -function readBounded(filePath: string, maxBytes: number): string { - const buf = Buffer.alloc(maxBytes); - const fd = openSync(filePath, "r"); - try { - const bytesRead = readSync(fd, buf, 0, maxBytes, 0); - return buf.toString("utf-8", 0, bytesRead); - } finally { - closeSync(fd); - } -} - -/** Common subdirectories where .xcodeproj may live in monorepos / standard layouts. */ -const XCODE_SUBDIRS = ["ios", "macos", "app", "apps"] as const; - -/** - * Scan *.xcodeproj directories for project.pbxproj and extract SDKROOT values. - * Returns deduplicated, canonical platform list (e.g. ["iphoneos"]). - * - * Reading the pbxproj is a lightweight regex scan — no full plist parsing needed. - * We read at most 1 MB per file to keep detection fast. - * Searches both the project root and common subdirectories (ios/, macos/, app/). - */ -function detectXcodePlatforms(basePath: string): XcodePlatform[] { - const platforms = new Set<XcodePlatform>(); - - // Directories to scan: project root + common subdirs - const dirsToScan = [basePath]; - for (const sub of XCODE_SUBDIRS) { - const subPath = join(basePath, sub); - if (existsSync(subPath)) dirsToScan.push(subPath); - } - - for (const dir of dirsToScan) { - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.endsWith(".xcodeproj")) continue; - const pbxprojPath = join(dir, entry.name, "project.pbxproj"); - try { - const content = readBounded(pbxprojPath, 1024 * 1024); - // Match SDKROOT = <value>; — both quoted and unquoted forms - const sdkRe = /SDKROOT\s*=\s*"?([a-z]+)"?\s*;/gi; - let m: RegExpExecArray | null; - let foundExplicit = false; - while ((m = sdkRe.exec(content)) !== null) { - const val = m[1].toLowerCase(); - if (val === "auto") continue; // handled below via SUPPORTED_PLATFORMS - const canonical = SDKROOT_MAP[val]; - if (canonical) { - platforms.add(canonical); - foundExplicit = true; - } - } - // Xcode 15+ defaults SDKROOT to "auto"; fall back to SUPPORTED_PLATFORMS - if (!foundExplicit) { - let sp: RegExpExecArray | null; - while ((sp = SUPPORTED_PLATFORMS_RE.exec(content)) !== null) { - for (const tok of sp[1].split(/\s+/)) { - const canonical = SDKROOT_MAP[tok.toLowerCase()]; - if (canonical) platforms.add(canonical); - } - } - SUPPORTED_PLATFORMS_RE.lastIndex = 0; - } - } catch { - // unreadable pbxproj — skip - } - } - } catch { - // unreadable directory - } - } - return [...platforms]; -} - -// ─── Package Manager Detection ────────────────────────────────────────────────── - -function detectPackageManager(basePath: string): string | undefined { - if (existsSync(join(basePath, "pnpm-lock.yaml"))) return "pnpm"; - if (existsSync(join(basePath, "yarn.lock"))) return "yarn"; - if (existsSync(join(basePath, "bun.lockb")) || existsSync(join(basePath, "bun.lock"))) return "bun"; - if (existsSync(join(basePath, "package-lock.json"))) return "npm"; - if (existsSync(join(basePath, "package.json"))) return "npm"; - return undefined; -} - -// ─── Verification Command Detection ───────────────────────────────────────────── - -/** - * Auto-detect verification commands from project files. - * Returns commands in priority order (test first, then build, then lint). - */ -function detectVerificationCommands( - basePath: string, - detectedFiles: string[], - packageManager?: string, -): string[] { - const commands: string[] = []; - const pm = packageManager ?? "npm"; - const run = pm === "npm" ? "npm run" : pm === "yarn" ? "yarn" : pm === "bun" ? "bun run" : `${pm} run`; - - if (detectedFiles.includes("package.json")) { - const scripts = readPackageJsonScripts(basePath); - if (scripts) { - // Test commands (highest priority) - if (scripts.test && scripts.test !== "echo \"Error: no test specified\" && exit 1") { - commands.push(pm === "npm" ? "npm test" : `${pm} test`); - } - // Build commands - if (scripts.build) { - commands.push(`${run} build`); - } - // Lint commands - if (scripts.lint) { - commands.push(`${run} lint`); - } - // Typecheck commands - if (scripts.typecheck) { - commands.push(`${run} typecheck`); - } else if (scripts.tsc) { - commands.push(`${run} tsc`); - } - } - } - - if (detectedFiles.includes("Cargo.toml")) { - commands.push("cargo test"); - commands.push("cargo clippy"); - } - - if (detectedFiles.includes("go.mod")) { - commands.push("go test ./..."); - commands.push("go vet ./..."); - } - - if (detectedFiles.includes("pyproject.toml") || detectedFiles.includes("setup.py") || detectedFiles.includes("requirements.txt")) { - commands.push("pytest"); - } - - if (detectedFiles.includes("Gemfile")) { - // Check for rspec vs minitest - if (existsSync(join(basePath, "spec"))) { - commands.push("bundle exec rspec"); - } else { - commands.push("bundle exec rake test"); - } - } - - if (detectedFiles.includes("Makefile")) { - const makeTargets = readMakefileTargets(basePath); - if (makeTargets.includes("test")) { - commands.push("make test"); - } - } - - return commands; -} - -// ─── Global Setup Detection ───────────────────────────────────────────────────── - -/** - * Check if global SF setup exists (has ~/.gsd/ with preferences). - */ -export function hasGlobalSetup(): boolean { - return ( - existsSync(join(gsdHome, "PREFERENCES.md")) || - existsSync(join(gsdHome, "preferences.md")) - ); -} - -/** - * Check if this is the very first time SF has been used on this machine. - * Returns true if ~/.gsd/ doesn't exist or has no preferences or auth. - */ -export function isFirstEverLaunch(): boolean { - if (!existsSync(gsdHome)) return true; - - // If we have preferences, not first launch - if ( - existsSync(join(gsdHome, "PREFERENCES.md")) || - existsSync(join(gsdHome, "preferences.md")) - ) { - return false; - } - - // If we have auth.json, not first launch (onboarding.ts already ran) - if (existsSync(join(gsdHome, "agent", "auth.json"))) return false; - - // Check legacy path too - const legacyPath = join(homedir(), ".pi", "agent", "gsd-preferences.md"); - if (existsSync(legacyPath)) return false; - - return true; -} - -// ─── Helpers ──────────────────────────────────────────────────────────────────── - -function packageJsonHasWorkspaces(basePath: string): boolean { - try { - const raw = readFileSync(join(basePath, "package.json"), "utf-8"); - const pkg = JSON.parse(raw); - return Array.isArray(pkg.workspaces) || (pkg.workspaces && typeof pkg.workspaces === "object"); - } catch { - return false; - } -} - -function readPackageJsonScripts(basePath: string): Record<string, string> | null { - try { - const raw = readFileSync(join(basePath, "package.json"), "utf-8"); - const pkg = JSON.parse(raw); - return pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : null; - } catch { - return null; - } -} - -function readMakefileTargets(basePath: string): string[] { - try { - const raw = readFileSync(join(basePath, "Makefile"), "utf-8"); - const targets: string[] = []; - for (const line of raw.split("\n")) { - const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):/); - if (match) targets.push(match[1]); - } - return targets; - } catch { - return []; - } -} - -function pushUnique(arr: string[], value: string): void { - if (!arr.includes(value)) arr.push(value); -} - -function matchesProjectFileMarker(scannedFile: string, marker: string): boolean { - const normalized = scannedFile.replaceAll("\\", "/"); - return ( - normalized === marker || - normalized.endsWith(`/${marker}`) - ); -} - -function isPythonRequirementsFile(relativePath: string): boolean { - const normalized = relativePath.replaceAll("\\", "/"); - const basename = normalized.slice(normalized.lastIndexOf("/") + 1); - return ( - basename === "requirements.txt" || - basename === "requirements.in" || - /^requirements([-.].+)?\.(txt|in)$/i.test(basename) || - /(^|\/)requirements\/.+\.(txt|in)$/i.test(normalized) - ); -} - -function containsFastapiDependency(basePath: string, relativePaths: string[]): boolean { - for (const relativePath of relativePaths) { - try { - const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = extractDependencyContent(relativePath, raw); - if (isPythonRequirementsFile(relativePath)) { - for (const line of content.split("\n")) { - if (extractRequirementName(line) === "fastapi") return true; - } - continue; - } - - if (relativePath.endsWith("pyproject.toml")) { - if (containsFastapiInPyproject(content)) return true; - } - } catch { - // unreadable file — continue scanning other candidate files - } - } - - return false; -} - -function containsSpringBootMarker( - basePath: string, - buildFiles: string[], - versionCatalogFiles: string[], - settingsFiles: string[], -): boolean { - const usedPluginAliases = new Set<string>(); - const usedLibraryAliases = new Set<string>(); - const catalogAccessors = resolveVersionCatalogAccessors(basePath, versionCatalogFiles, settingsFiles); - - for (const relativePath of buildFiles) { - try { - const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = stripDependencyComments(relativePath, raw); - if (containsDirectSpringBootReference(relativePath, content)) { - return true; - } - - const normalized = content.toLowerCase(); - let match: RegExpExecArray | null; - for (const accessor of catalogAccessors) { - const aliasRe = new RegExp(`alias\\(\\s*${accessor}\\.plugins\\.([a-z0-9_.-]+)\\s*\\)`, "gi"); - while ((match = aliasRe.exec(normalized)) !== null) { - usedPluginAliases.add(normalizePluginAlias(match[1])); - } - - const libraryAliasRe = new RegExp(`\\b${accessor}\\.((?!plugins\\b)[a-z0-9_.-]+)`, "gi"); - while ((match = libraryAliasRe.exec(normalized)) !== null) { - usedLibraryAliases.add(normalizePluginAlias(match[1])); - } - } - } catch { - // unreadable build file — continue scanning others - } - } - - if (usedPluginAliases.size === 0 && usedLibraryAliases.size === 0) { - return false; - } - if (versionCatalogFiles.length === 0) { - return false; - } - - const springBootAliases = new Set<string>(); - const springBootLibraries = new Set<string>(); - const pendingSpringBootBundles: Array<{ bundleAlias: string; referencedAliases: string[] }> = []; - for (const relativePath of versionCatalogFiles) { - try { - const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = stripDependencyComments(relativePath, raw); - const aliasRe = /^\s*([A-Za-z0-9_.-]+)\s*=\s*\{[^\n}]*\bid\s*=\s*["']org\.springframework\.boot["'][^\n}]*\}/gm; - let match: RegExpExecArray | null; - while ((match = aliasRe.exec(content)) !== null) { - springBootAliases.add(normalizePluginAlias(match[1])); - } - - const libraryRe = /^\s*([A-Za-z0-9_.-]+)\s*=\s*\{[^\n}]*\b(module\s*=\s*["']org\.springframework\.boot:[^"']+["']|group\s*=\s*["']org\.springframework\.boot["'][^\n}]*\bname\s*=\s*["']spring-boot[^"']*["'])[^\n}]*\}/gm; - while ((match = libraryRe.exec(content)) !== null) { - springBootLibraries.add(normalizePluginAlias(match[1])); - } - - const bundleRe = /^\s*([A-Za-z0-9_.-]+)\s*=\s*\[([\s\S]*?)\]/gm; - while ((match = bundleRe.exec(content)) !== null) { - pendingSpringBootBundles.push({ - bundleAlias: normalizePluginAlias(`bundles.${match[1]}`), - referencedAliases: match[2] - .split(",") - .map((part) => normalizePluginAlias(part.replace(/["'\s]/g, ""))) - .filter(Boolean), - }); - } - } catch { - // unreadable version catalog — continue scanning others - } - } - - const springBootBundles = new Set<string>(); - for (const pendingBundle of pendingSpringBootBundles) { - if (pendingBundle.referencedAliases.some((alias) => springBootLibraries.has(alias))) { - springBootBundles.add(pendingBundle.bundleAlias); - } - } - - for (const alias of usedPluginAliases) { - if (springBootAliases.has(alias)) return true; - } - for (const alias of usedLibraryAliases) { - if (springBootLibraries.has(alias) || springBootBundles.has(alias)) return true; - } - - return false; -} - -function stripDependencyComments(relativePath: string, content: string): string { - if (relativePath.endsWith("requirements.txt")) { - return content.replace(/(^|\s)#.*$/gm, ""); - } - if (relativePath.endsWith("pyproject.toml")) { - return content.replace(/(^|\s)#.*$/gm, ""); - } - if (relativePath.endsWith(".versions.toml")) { - return content.replace(/(^|\s)#.*$/gm, ""); - } - if (relativePath.endsWith("settings.gradle") || relativePath.endsWith("settings.gradle.kts")) { - return content - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - } - if (relativePath.endsWith("pom.xml")) { - return content.replace(/<!--[\s\S]*?-->/g, ""); - } - if (relativePath.endsWith("build.gradle") || relativePath.endsWith("build.gradle.kts")) { - return content - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - } - return content; -} - -function extractDependencyContent(relativePath: string, content: string): string { - const stripped = stripDependencyComments(relativePath, content); - if (relativePath.endsWith("pyproject.toml")) { - return extractPyprojectDependencySections(stripped); - } - return stripped; -} - -function extractRequirementName(spec: string): string | null { - const trimmed = spec.trim().replace(/^["']|["']$/g, ""); - if (!trimmed) return null; - - const match = trimmed.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?(?=\s*(?:@|[<>=!~;]|$))/); - if (!match) return null; - return normalizePackageName(match[1]); -} - -function containsFastapiInPyproject(content: string): boolean { - for (const line of content.split("\n")) { - const keyMatch = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/); - if (keyMatch) { - const key = normalizePackageName(keyMatch[1]); - if (key === "fastapi") { - return true; - } - if (key !== "dependencies") { - continue; - } - } - - const quotedSpecRe = /["']([^"']+)["']/g; - let match: RegExpExecArray | null; - while ((match = quotedSpecRe.exec(line)) !== null) { - if (extractRequirementName(match[1]) === "fastapi") { - return true; - } - } - } - - return false; -} - -function containsDirectSpringBootReference(relativePath: string, content: string): boolean { - if (relativePath.endsWith("pom.xml")) { - return /<groupId>\s*org\.springframework\.boot\s*<\/groupId>/i.test(content); - } - - if (relativePath.endsWith("build.gradle") || relativePath.endsWith("build.gradle.kts")) { - return /(id\s*\(?\s*["']org\.springframework\.boot["']|apply\s*\(?\s*plugin\s*[:=]\s*["']org\.springframework\.boot["']|(?:implementation|api|compileOnly|runtimeOnly|testImplementation|annotationProcessor|kapt)\s*\(?\s*["'][^"']*org\.springframework\.boot:[^"']*spring-boot[^"']*["'])/i.test(content); - } - - return false; -} - -function extractPyprojectDependencySections(content: string): string { - const lines = content.split("\n"); - const collected: string[] = []; - let section = ""; - let collectingProjectDeps = false; - let collectingOptionalDeps = false; - let bracketDepth = 0; - - for (const line of lines) { - const trimmed = line.trim(); - - if (collectingProjectDeps) { - collected.push(line); - bracketDepth += countChar(line, "[") - countChar(line, "]"); - if (bracketDepth <= 0) { - collectingProjectDeps = false; - } - continue; - } - - if (collectingOptionalDeps) { - collected.push(line); - bracketDepth += countChar(line, "[") - countChar(line, "]"); - if (bracketDepth <= 0) { - collectingOptionalDeps = false; - } - continue; - } - - const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); - if (sectionMatch) { - section = sectionMatch[1].trim(); - continue; - } - - if (section === "project" && /^dependencies\s*=\s*\[/.test(trimmed)) { - collected.push(line); - bracketDepth = countChar(line, "[") - countChar(line, "]"); - collectingProjectDeps = bracketDepth > 0; - continue; - } - - if ( - section === "project.optional-dependencies" || - section === "tool.poetry.dependencies" - ) { - if (section === "project.optional-dependencies") { - const equalsIndex = line.indexOf("="); - if (equalsIndex !== -1) { - const value = line.slice(equalsIndex + 1); - collected.push(value); - bracketDepth = countChar(value, "[") - countChar(value, "]"); - collectingOptionalDeps = bracketDepth > 0; - } - } else { - collected.push(line); - } - } - } - - return collected.join("\n"); -} - -function countChar(text: string, char: string): number { - return [...text].filter((c) => c === char).length; -} - -function normalizePackageName(name: string): string { - return name.toLowerCase().replace(/[_.]/g, "-"); -} - -function normalizePluginAlias(alias: string): string { - return alias.toLowerCase().replace(/[-_]/g, "."); -} - -function versionCatalogAccessorName(relativePath: string): string { - const normalized = relativePath.replaceAll("\\", "/"); - const basename = normalized.slice(normalized.lastIndexOf("/") + 1); - return basename.replace(/\.versions\.toml$/i, "").toLowerCase(); -} - -function resolveVersionCatalogAccessors( - basePath: string, - versionCatalogFiles: string[], - settingsFiles: string[], -): Set<string> { - const accessors = new Set(versionCatalogFiles.map(versionCatalogAccessorName).filter(Boolean)); - if (versionCatalogFiles.length === 0 || settingsFiles.length === 0) { - return accessors; - } - - for (const settingsFile of settingsFiles) { - try { - const raw = readBounded(join(basePath, settingsFile), 64 * 1024); - const content = stripDependencyComments(settingsFile, raw); - const createRe = /create\(\s*["']([A-Za-z0-9_]+)["']\s*\)\s*\{[\s\S]*?([A-Za-z0-9_.-]+\.versions\.toml)["']?\s*\)\s*\)/g; - let match: RegExpExecArray | null; - while ((match = createRe.exec(content)) !== null) { - const accessor = match[1].toLowerCase(); - const catalogBasename = match[2].replaceAll("\\", "/").split("/").pop()!; - if (versionCatalogFiles.some((file) => { - const normalized = file.replaceAll("\\", "/"); - return normalized === catalogBasename || normalized.endsWith(`/${catalogBasename}`); - })) { - accessors.add(accessor); - } - } - } catch { - // unreadable settings file — ignore - } - } - - return accessors; -} - -export function scanProjectFiles(basePath: string): string[] { - const files: string[] = []; - const queue: Array<{ path: string; depth: number }> = [{ path: basePath, depth: 0 }]; - - while (queue.length > 0 && files.length < MAX_RECURSIVE_SCAN_FILES) { - const current = queue.shift()!; - let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>; - try { - entries = readdirSync(current.path, { withFileTypes: true, encoding: "utf8" }); - } catch { - continue; - } - - for (const entry of entries) { - const entryPath = join(current.path, entry.name); - const relativePath = entryPath.slice(basePath.length + 1); - - if (entry.isDirectory()) { - if (current.depth < MAX_RECURSIVE_SCAN_DEPTH && !RECURSIVE_SCAN_IGNORED_DIRS.has(entry.name)) { - queue.push({ path: entryPath, depth: current.depth + 1 }); - } - continue; - } - - if (!entry.isFile()) continue; - files.push(relativePath); - if (files.length >= MAX_RECURSIVE_SCAN_FILES) break; - } - } - - return files; -} diff --git a/src/resources/extensions/gsd/dev-execution-policy.ts b/src/resources/extensions/gsd/dev-execution-policy.ts deleted file mode 100644 index 96f657724..000000000 --- a/src/resources/extensions/gsd/dev-execution-policy.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * dev-execution-policy.ts — DevExecutionPolicy implementation. - * - * Stub policy for the dev engine. All methods return safe defaults. - * Real verification/closeout continues running through phases.ts via LoopDeps. - * Wiring this policy into the loop is S04's responsibility. - */ - -import type { ExecutionPolicy } from "./execution-policy.js"; -import type { RecoveryAction, CloseoutResult } from "./engine-types.js"; - -export class DevExecutionPolicy implements ExecutionPolicy { - async prepareWorkspace( - _basePath: string, - _milestoneId: string, - ): Promise<void> { - // no-op — workspace preparation handled by existing SF logic - } - - async selectModel( - _unitType: string, - _unitId: string, - _context: { basePath: string }, - ): Promise<{ tier: string; modelDowngraded: boolean } | null> { - return null; // use default model selection - } - - async verify( - _unitType: string, - _unitId: string, - _context: { basePath: string }, - ): Promise<"continue" | "retry" | "pause"> { - return "continue"; - } - - async recover( - _unitType: string, - _unitId: string, - _context: { basePath: string }, - ): Promise<RecoveryAction> { - return { outcome: "retry" }; - } - - async closeout( - _unitType: string, - _unitId: string, - _context: { basePath: string; startedAt: number }, - ): Promise<CloseoutResult> { - return { committed: false, artifacts: [] }; - } -} diff --git a/src/resources/extensions/gsd/dev-workflow-engine.ts b/src/resources/extensions/gsd/dev-workflow-engine.ts deleted file mode 100644 index 6d79cc22b..000000000 --- a/src/resources/extensions/gsd/dev-workflow-engine.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * dev-workflow-engine.ts — DevWorkflowEngine implementation. - * - * Implements WorkflowEngine by delegating to existing SF state derivation - * and dispatch logic. This is the "dev" engine — it wraps the current SF - * auto-mode behavior behind the engine-polymorphic interface. - */ - -import type { WorkflowEngine } from "./workflow-engine.js"; -import type { - EngineState, - EngineDispatchAction, - CompletedStep, - ReconcileResult, - DisplayMetadata, -} from "./engine-types.js"; -import type { GSDState } from "./types.js"; -import type { DispatchAction, DispatchContext } from "./auto-dispatch.js"; - -import { deriveState } from "./state.js"; -import { resolveDispatch } from "./auto-dispatch.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; - -// ─── Bridge: DispatchAction → EngineDispatchAction ──────────────────────── - -/** - * Map a SF-specific DispatchAction (which carries `matchedRule`, `unitType`, - * etc.) to the engine-generic EngineDispatchAction discriminated union. - * - * Exported for unit testing. - */ -export function bridgeDispatchAction(da: DispatchAction): EngineDispatchAction { - switch (da.action) { - case "dispatch": - return { - action: "dispatch", - step: { - unitType: da.unitType, - unitId: da.unitId, - prompt: da.prompt, - }, - }; - case "stop": - return { - action: "stop", - reason: da.reason, - level: da.level, - }; - case "skip": - return { action: "skip" }; - } -} - -// ─── DevWorkflowEngine ─────────────────────────────────────────────────── - -export class DevWorkflowEngine implements WorkflowEngine { - readonly engineId = "dev" as const; - - async deriveState(basePath: string): Promise<EngineState> { - const gsd: GSDState = await deriveState(basePath); - return { - phase: gsd.phase, - currentMilestoneId: gsd.activeMilestone?.id ?? null, - activeSliceId: gsd.activeSlice?.id ?? null, - activeTaskId: gsd.activeTask?.id ?? null, - isComplete: gsd.phase === "complete", - raw: gsd, - }; - } - - async resolveDispatch( - state: EngineState, - context: { basePath: string }, - ): Promise<EngineDispatchAction> { - const gsd = state.raw as GSDState; - const mid = gsd.activeMilestone?.id ?? ""; - const midTitle = gsd.activeMilestone?.title ?? ""; - const loaded = loadEffectiveGSDPreferences(); - const prefs = loaded?.preferences ?? undefined; - - const dispatchCtx: DispatchContext = { - basePath: context.basePath, - mid, - midTitle, - state: gsd, - prefs, - }; - - const result = await resolveDispatch(dispatchCtx); - return bridgeDispatchAction(result); - } - - async reconcile( - state: EngineState, - _completedStep: CompletedStep, - ): Promise<ReconcileResult> { - return { - outcome: state.isComplete ? "milestone-complete" : "continue", - }; - } - - getDisplayMetadata(state: EngineState): DisplayMetadata { - return { - engineLabel: "SF Dev", - currentPhase: state.phase, - progressSummary: `${state.currentMilestoneId ?? "no milestone"} / ${state.activeSliceId ?? "—"} / ${state.activeTaskId ?? "—"}`, - stepCount: null, - }; - } -} diff --git a/src/resources/extensions/gsd/diff-context.ts b/src/resources/extensions/gsd/diff-context.ts deleted file mode 100644 index cf00d24b5..000000000 --- a/src/resources/extensions/gsd/diff-context.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Diff-aware context module — prioritizes recently-changed files when building - * context for the AI agent. Uses git diff/status to discover changes, then - * provides ranking utilities for context-window budget allocation. - * - * Standalone module: only imports node:child_process and node:path. - */ - -import { execFileSync, execFile } from "node:child_process"; -import { resolve } from "node:path"; -import { GSDError, SF_PARSE_ERROR } from "./errors.js"; - -// ─── Types ────────────────────────────────────────────────────────────────── - -export interface ChangedFileInfo { - path: string; - changeType: "modified" | "added" | "deleted" | "staged"; - linesChanged?: number; -} - -export interface RecentFilesOptions { - /** Maximum number of files to return (default 20) */ - maxFiles?: number; - /** Only consider commits within this many days (default 7) */ - sinceDays?: number; -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const EXEC_OPTS = { - encoding: "utf-8" as const, - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], -}; - -/** Synchronous git — used where sequential control flow is required (fallback paths). */ -function gitSync(args: string[], cwd: string): string { - return execFileSync("git", args, { ...EXEC_OPTS, cwd }).trim(); -} - -/** Async git — returns stdout on success, empty string on any error. */ -function gitAsync(args: string[], cwd: string): Promise<string> { - return new Promise((resolve) => { - execFile( - "git", - args, - { encoding: "utf-8", timeout: 5000, cwd }, - (err, stdout) => resolve(err ? "" : stdout.trim()), - ); - }); -} - -function splitLines(output: string): string[] { - return output - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); -} - -// ─── Public API ───────────────────────────────────────────────────────────── - -/** - * Returns recently-changed file paths, deduplicated and sorted by recency - * (most recent first). Combines committed diffs, staged changes, and - * unstaged/untracked files from `git status`. - * - * The three git queries (log, diff --cached, status) run concurrently. - */ -export async function getRecentlyChangedFiles( - cwd: string, - options?: RecentFilesOptions, -): Promise<string[]> { - const maxFiles = options?.maxFiles ?? 20; - const sinceDays = options?.sinceDays ?? 7; - const dir = resolve(cwd); - - try { - const days = Math.max(1, Math.floor(Number(sinceDays))); - if (!Number.isFinite(days)) throw new GSDError(SF_PARSE_ERROR, "invalid sinceDays"); - - // Run all three queries concurrently — they read independent git state - const [logRaw, stagedRaw, statusRaw] = await Promise.all([ - // 1. Committed changes since N days ago (fallback to HEAD~10 on error) - gitAsync(["log", "--diff-filter=ACMR", "--name-only", "--pretty=format:", `--since=${days} days ago`], dir) - .then((out) => out || gitAsync(["diff", "--name-only", "HEAD~10"], dir)), - // 2. Staged changes - gitAsync(["diff", "--cached", "--name-only"], dir), - // 3. Unstaged / untracked - gitAsync(["status", "--porcelain"], dir), - ]); - - const committedFiles = splitLines(logRaw); - const stagedFiles = splitLines(stagedRaw); - const statusFiles = splitLines(statusRaw).map((line) => line.slice(3)); // strip XY + space - - // Deduplicate, preserving insertion order (most-recent-first: status → staged → committed) - const seen = new Set<string>(); - const result: string[] = []; - for (const file of [...statusFiles, ...stagedFiles, ...committedFiles]) { - if (!seen.has(file)) { - seen.add(file); - result.push(file); - } - } - - return result.slice(0, maxFiles); - } catch { - // Non-git directory or git unavailable — graceful fallback - return []; - } -} - -/** - * Returns richer change metadata: change type and approximate line counts. - * - * The three git queries (diff --cached --numstat, diff --numstat, status --porcelain) - * run concurrently — they read independent git state. - */ -export async function getChangedFilesWithContext( - cwd: string, -): Promise<ChangedFileInfo[]> { - const dir = resolve(cwd); - - try { - // Run all three queries concurrently - const [cachedNumstat, unstagedNumstat, statusRaw] = await Promise.all([ - gitAsync(["diff", "--cached", "--numstat"], dir), - gitAsync(["diff", "--numstat"], dir), - gitAsync(["status", "--porcelain"], dir), - ]); - - const result: ChangedFileInfo[] = []; - const seen = new Set<string>(); - - const add = (info: ChangedFileInfo) => { - if (!seen.has(info.path)) { - seen.add(info.path); - result.push(info); - } - }; - - // 1. Staged files with numstat - for (const line of splitLines(cachedNumstat)) { - const [added, deleted, filePath] = line.split("\t"); - if (!filePath) continue; - const lines = - added === "-" || deleted === "-" - ? undefined - : Number(added) + Number(deleted); - add({ path: filePath, changeType: "staged", linesChanged: lines }); - } - - // 2. Unstaged modifications with numstat - for (const line of splitLines(unstagedNumstat)) { - const [added, deleted, filePath] = line.split("\t"); - if (!filePath) continue; - const lines = - added === "-" || deleted === "-" - ? undefined - : Number(added) + Number(deleted); - add({ path: filePath, changeType: "modified", linesChanged: lines }); - } - - // 3. Untracked / deleted from porcelain status - for (const line of splitLines(statusRaw)) { - const code = line.slice(0, 2); - const filePath = line.slice(3); - if (seen.has(filePath)) continue; - - if (code.includes("?")) { - add({ path: filePath, changeType: "added" }); - } else if (code.includes("D")) { - add({ path: filePath, changeType: "deleted" }); - } else if (code.includes("A")) { - add({ path: filePath, changeType: "added" }); - } else { - add({ path: filePath, changeType: "modified" }); - } - } - - return result; - } catch { - return []; - } -} - -/** - * Ranks a file list so that recently-changed files appear first. - * Files present in `changedFiles` are placed at the front (in their - * original changedFiles order), followed by unchanged files in their - * original order. - */ -export function rankFilesByRelevance( - files: string[], - changedFiles: string[], -): string[] { - const changedSet = new Set(changedFiles); - const changed: string[] = []; - const rest: string[] = []; - - for (const f of files) { - if (changedSet.has(f)) { - changed.push(f); - } else { - rest.push(f); - } - } - - // Maintain changedFiles priority order within the changed group - const changedOrder = new Map(changedFiles.map((f, i) => [f, i])); - changed.sort((a, b) => (changedOrder.get(a) ?? 0) - (changedOrder.get(b) ?? 0)); - - return [...changed, ...rest]; -} diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts deleted file mode 100644 index 85274e00b..000000000 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ /dev/null @@ -1,143 +0,0 @@ -// SF Dispatch Guard — prevents out-of-order slice dispatch - -import { resolveMilestoneFile } from "./paths.js"; -import { findMilestoneIds } from "./guided-flow.js"; -import { parseUnitId } from "./unit-id.js"; -import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; -import { parseRoadmap } from "./parsers-legacy.js"; -import { isClosedStatus } from "./status-guards.js"; -import { readFileSync } from "node:fs"; - -const SLICE_DISPATCH_TYPES = new Set([ - "research-slice", - "plan-slice", - "replan-slice", - "execute-task", - "complete-slice", -]); - -export function getPriorSliceCompletionBlocker( - base: string, - _mainBranch: string, - unitType: string, - unitId: string, -): string | null { - if (!SLICE_DISPATCH_TYPES.has(unitType)) return null; - - const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId); - if (!targetMid || !targetSid) return null; - - // Parallel worker isolation: when SF_MILESTONE_LOCK is set, this worker - // is scoped to a single milestone. Skip the cross-milestone dependency - // check — other milestones are being handled by their own workers. - // Without this, the dispatch guard sees incomplete slices in M010/M011 - // (cloned into the worktree DB) and blocks M012 from ever starting. #2797 - const milestoneLock = process.env.SF_MILESTONE_LOCK; - - // Use findMilestoneIds to respect custom queue order. - // Only check milestones that come BEFORE the target in queue order. - // When locked to a specific milestone, only check that milestone's - // intra-slice dependencies — skip all cross-milestone checks. - const allIds = milestoneLock && targetMid === milestoneLock - ? [targetMid] - : findMilestoneIds(base); - const targetIdx = allIds.indexOf(targetMid); - if (targetIdx < 0) return null; - const milestoneIds = allIds.slice(0, targetIdx + 1); - - for (const mid of milestoneIds) { - if (resolveMilestoneFile(base, mid, "PARKED")) continue; - if (resolveMilestoneFile(base, mid, "SUMMARY")) continue; - - // Normalised slice list from DB or file fallback - type NormSlice = { id: string; done: boolean; depends: string[] }; - let slices: NormSlice[] | null = null; - - if (isDbAvailable()) { - const rows = getMilestoneSlices(mid); - if (rows.length > 0) { - slices = rows.map((r) => ({ - id: r.id, - done: isClosedStatus(r.status), - depends: r.depends ?? [], - })); - } - } - if (!slices) { - // File-based fallback: parse roadmap checkboxes - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) continue; - let roadmapContent: string; - try { roadmapContent = readFileSync(roadmapPath, "utf-8"); } catch { continue; } - const parsed = parseRoadmap(roadmapContent); - if (parsed.slices.length === 0) continue; - slices = parsed.slices.map((s) => ({ - id: s.id, - done: s.done, - depends: s.depends ?? [], - })); - } - - if (mid !== targetMid) { - const incomplete = slices.find((slice) => !slice.done); - if (incomplete) { - return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete.`; - } - continue; - } - - const targetSlice = slices.find((slice) => slice.id === targetSid); - if (!targetSlice) return null; - - // Dependency-aware ordering: if the target slice declares dependencies, - // only require those specific slices to be complete — not all positionally - // earlier slices. This prevents deadlocks when a positionally-earlier - // slice depends on a positionally-later one (e.g. S05 depends_on S06). - // - // When the target has NO declared dependencies, fall back to the original - // positional ordering for backward compatibility. - if (targetSlice.depends.length > 0) { - const sliceMap = new Map(slices.map((s) => [s.id, s])); - for (const depId of targetSlice.depends) { - const dep = sliceMap.get(depId); - if (dep && !dep.done) { - return `Cannot dispatch ${unitType} ${unitId}: dependency slice ${targetMid}/${depId} is not complete.`; - } - // If dep is not found in this milestone's slices, ignore it — - // it may be a cross-milestone reference handled elsewhere. - } - } else { - const milestoneUsesExplicitDeps = slices.some((slice) => slice.depends.length > 0); - if (milestoneUsesExplicitDeps) { - return null; - } - - // Positional fallback is only a heuristic for legacy slices with no - // declared dependencies. Skip any earlier slice that depends on the - // target, directly or transitively, or we can deadlock a valid zero-dep - // slice behind its own downstream dependents (#3720). - const reverseDependents = new Set<string>(); - let changed = true; - while (changed) { - changed = false; - for (const slice of slices) { - if (reverseDependents.has(slice.id)) continue; - if (slice.depends.some((depId) => depId === targetSid || reverseDependents.has(depId))) { - reverseDependents.add(slice.id); - changed = true; - } - } - } - - const targetIndex = slices.findIndex((slice) => slice.id === targetSid); - const incomplete = slices - .slice(0, targetIndex) - .find((slice) => !slice.done && !reverseDependents.has(slice.id)); - if (incomplete) { - return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; - } - } - } - - return null; -} diff --git a/src/resources/extensions/gsd/docs/claude-marketplace-import.md b/src/resources/extensions/gsd/docs/claude-marketplace-import.md deleted file mode 100644 index 753a1ac1d..000000000 --- a/src/resources/extensions/gsd/docs/claude-marketplace-import.md +++ /dev/null @@ -1,214 +0,0 @@ -# Claude Marketplace Import - -This document describes the Claude marketplace import feature in SF: what it reads, what it imports, what it persists, and what it does not translate into active SF/Pi runtime behavior. - ---- - -## What this feature does - -SF can read Claude Code marketplace catalogs, inspect the plugins they reference, and import selected Claude skills into SF/Pi while preserving Claude-style namespace identity. - -The interactive entry point is: - -```text -/gsd prefs import-claude -``` - -You can also choose scope explicitly: - -```text -/gsd prefs import-claude global -/gsd prefs import-claude project -``` - ---- - -## Claude Code model this feature follows - -Anthropic documents Claude marketplaces as sources users add with: - -```text -/plugin marketplace add <github repo or local path> -``` - -A marketplace contains a catalog at: - -```text -.claude-plugin/marketplace.json -``` - -Anthropic distinguishes between: - -- **Marketplace source** — where Claude fetches `marketplace.json` -- **Plugin source** — where Claude fetches each plugin listed in that marketplace -- **Installed plugin cache** — Claude copies installed plugin payloads into: - -```text -~/.claude/plugins/cache -``` - -Anthropic also documents user-added marketplace sources under: - -```text -~/.claude/plugins/marketplaces -``` - -SF aligns its Claude import flow to that model. - ---- - -## Where SF looks - -For Claude plugin and marketplace material, SF prefers Claude-managed locations first: - -1. `~/.claude/plugins/marketplaces` -2. `~/.claude/plugins/cache` -3. `~/.claude/plugins` - -After that, SF still allows local clone-style convenience paths such as sibling repos or `~/repos/...` paths. Those fallbacks remain supported for developer workflows, but they are not the primary Claude storage model. - ---- - -## What SF imports - -### Imported into SF/Pi settings - -- Claude skills discovered directly from configured skill roots -- Marketplace-derived skills - -Imported marketplace skills preserve canonical namespace identity, for example: - -```text -python3-development:stinkysnake -scientific-method:experiment-protocol -``` - -### Discovered, modeled, and validated - -- Marketplace-derived agents - -### Discovered but not translated into active Pi-native runtime behavior - -- hooks -- MCP server definitions -- LSP server definitions -- other plugin metadata that does not currently map directly into active SF/Pi runtime surfaces - ---- - -## Import flow - -The import flow does the following: - -1. discover Claude skills and marketplace/plugin roots -2. identify marketplace roots by checking for `.claude-plugin/marketplace.json` -3. inspect discovered plugins and inventory their components -4. let you select components to import -5. validate the selection for canonical conflicts and ambiguity -6. persist imported resources into SF/Pi settings - ---- - -## Namespace behavior - -SF preserves Claude plugin namespace semantics rather than flattening plugin components into anonymous global names. - -### Canonical references - -Canonical references remain available for imported components: - -- skills: `plugin-name:skill-name` -- agents: `plugin-name:agent-name` - -### Shorthand - -SF supports shorthand lookup when it is unambiguous. - -### Local-first resolution - -When a namespaced component refers to another component by bare name, SF tries the same plugin namespace first before broader lookup. - ---- - -## Important safeguard: marketplace agent directories are not stored as package sources - -Claude plugin agent directories are markdown agent-definition directories, for example: - -```text -.../plugins/python3-development/agents -``` - -SF does **not** persist imported marketplace agent directories into: - -```json -settings.packages -``` - -This is intentional. - -### Why - -Persisting an `.../agents` directory into `settings.packages` can cause Pi startup to treat that directory as an extension/package root. In real host validation, that produced extension loader failures such as: - -```text -Cannot find module '.../agents' -``` - -SF now avoids writing those entries. - ---- - -## Settings effects - -### Skills - -Imported skills are persisted into Pi skill settings. Depending on the selection path, they may also be added to SF preferences. - -### Marketplace agents - -Marketplace agents remain part of the import model and validation surface, but their `agents/` directories are not persisted as package roots. - ---- - -## Diagnostics - -SF distinguishes between: - -- **canonical conflicts** — hard errors -- **shorthand overlaps** — warnings when canonical names remain distinct -- **alias conflicts** — diagnostics for alias collisions or shadowing - -This allows imported marketplace content to be validated without reporting valid overlap as fatal breakage. - ---- - -## Verification status of this feature - -This feature has been verified in three ways: - -1. **Contract/unit tests** for parsing, namespacing, resolution, diagnostics, and import behavior -2. **Portable integration-style tests** using local or cloned marketplace fixtures -3. **Real host validation** against the installed `gsd` binary and actual Claude-managed directories on the host machine - -Real host validation included: - -- clean startup of the installed `gsd` binary after fixing stale bad settings -- successful invocation of an imported skill (`/stinkysnake`) -- successful execution of `/gsd prefs import-claude global` -- verification that imported marketplace agent directories were **not** reintroduced into `settings.packages` - ---- - -## Current limitations - -- SF does not yet translate every Claude plugin component type into active Pi-native runtime behavior -- marketplace-derived agents are not persisted as package roots, by design -- clone-style local fallbacks still exist for developer convenience, even though Claude-managed marketplace/plugin locations are preferred first - ---- - -## References - -- Anthropic: Claude Code settings -- Anthropic: Create and distribute a plugin marketplace -- Anthropic: Plugins and plugin reference diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md deleted file mode 100644 index e7c3549b5..000000000 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ /dev/null @@ -1,694 +0,0 @@ -# SF Preferences Reference - -Full documentation for `~/.gsd/PREFERENCES.md` (global) and `.gsd/PREFERENCES.md` (project). - ---- - -## Notes - -- Keep this skill-first. -- Prefer explicit skill names or absolute paths. -- Use absolute paths for personal/local skills when you want zero ambiguity. -- These preferences guide which skills SF should load and follow; they do not override higher-priority instructions in the current conversation. -- For Claude marketplace/plugin import behavior, see `~/.gsd/agent/extensions/gsd/docs/claude-marketplace-import.md`. - ---- - -## Semantics - -### Empty Arrays vs Omitted Fields - -**Empty arrays (`[]`) are equivalent to omitting the field entirely.** During validation, SF deletes empty arrays from the preferences object (see `validatePreferences()` in `preferences.ts`): - -```typescript -for (const key of [ - "always_use_skills", - "prefer_skills", - "avoid_skills", - "custom_instructions", -] as const) { - if (validated[key] && validated[key]!.length === 0) { - delete validated[key]; - } -} -``` - -These are functionally identical: - -```yaml -# Explicit empty arrays — will be normalized away -prefer_skills: [] -avoid_skills: [] -skill_rules: [] - -# Omitted entirely — same result -# (just don't write these fields) -``` - -**Recommendation:** Omit fields you don't need. Empty arrays add noise with no effect. - -### Global vs Project Preferences - -Preferences are loaded from two locations and merged: - -1. **Global:** `~/.gsd/PREFERENCES.md` — applies to all projects -2. **Project:** `.gsd/PREFERENCES.md` — applies to the current project only - -**Merge behavior** (see `mergePreferences()` in `preferences.ts`): - -- **Scalar fields** (`skill_discovery`, `budget_ceiling`, etc.): Project wins if defined, otherwise global. Uses nullish coalescing (`??`). -- **Array fields** (`always_use_skills`, `prefer_skills`, etc.): Concatenated via `mergeStringLists()` (global first, then project). -- **Object fields** (`models`, `git`, `auto_supervisor`): Shallow merge via spread operator `{ ...base, ...override }`. - -For `models`, project settings override global at the phase level. If global has `planning: opus` and project has `planning: sonnet`, the project wins. But if project omits `research`, global's `research` setting is preserved. - -### Skill Discovery vs Skill Preferences - -These are **separate concerns**: - -| Field | What it controls | Code reference | -| ---------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | -| `skill_discovery` | **Whether** SF looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` | -| `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` | - -Setting `prefer_skills: []` does **not** disable skill discovery — it just means you have no preference overrides. Use `skill_discovery: off` to disable discovery entirely. - ---- - -## Field Guide - -- `version`: schema version. Start at `1`. - -- `mode`: workflow mode — `"solo"` or `"team"`. Sets sensible defaults for git and project settings based on your workflow. Mode defaults are the lowest priority layer — any explicit preference overrides them. Omit to configure everything manually. - - | Setting | `solo` | `team` | - | ---------------------- | ------------ | ------------ | - | `git.auto_push` | `true` | `false` | - | `git.push_branches` | `false` | `true` | - | `git.pre_merge_check` | `false` | `true` | - | `git.merge_strategy` | `"squash"` | `"squash"` | - | `git.isolation` | `"worktree"` | `"worktree"` | - | `unique_milestone_ids` | `false` | `true` | - - Quick setup: `/gsd mode` (global) or `/gsd mode project` (project-level). - -- `always_use_skills`: skills SF should use whenever they are relevant. - -- `prefer_skills`: soft defaults SF should prefer when relevant. - -- `avoid_skills`: skills SF should avoid unless clearly needed. - -- `skill_rules`: situational rules with a human-readable `when` trigger and one or more of `use`, `prefer`, or `avoid`. - -- `custom_instructions`: extra durable instructions related to skill use. For operational project knowledge (recurring rules, gotchas, patterns), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically and agents can append to it during execution. - -- `models`: per-stage model selection (applies to both auto-mode and guided-flow dispatches). Keys: `research`, `planning`, `discuss`, `execution`, `execution_simple`, `completion`, `validation`, `subagent`. Values can be: - - Simple string: `"claude-sonnet-4-6"` — single model, no fallbacks - - Provider-qualified string: `"bedrock/claude-sonnet-4-6"` — targets a specific provider when the same model ID exists across multiple providers - - Object with fallbacks: `{ model: "claude-opus-4-6", fallbacks: ["glm-5", "minimax-m2.5"] }` — tries fallbacks in order if primary fails - - Object with provider: `{ model: "claude-opus-4-6", provider: "bedrock" }` — explicit provider targeting in object format - - Omit a key to use whatever model is currently active (except `discuss` and `validation` which fall back to `planning` when unset). Fallbacks are tried when model switching fails (provider unavailable, rate limited, etc.). - - `discuss` — used for milestone/slice discussion (interactive context gathering). Falls back to `planning` if unset. - - `validation` — used for gate evaluation, roadmap reassessment, milestone validation, and doc rewrites. Falls back to `planning` if unset. - -- `persist_model_changes`: boolean — controls whether `setModel()` updates also persist to the default provider/model settings. Default: `true`. Set `false` to keep auto-mode and recovery model switches session-local. - -- `skill_staleness_days`: number — skills unused for this many days get deprioritized during discovery. Set to `0` to disable staleness tracking. Default: `60`. - -- `skill_discovery`: controls how SF discovers and applies skills during auto-mode. Valid values: - - `auto` — skills are found and applied automatically without prompting. - - `suggest` — (default) skills are identified during research but not installed automatically. - - `off` — skill discovery is disabled entirely. - -- `auto_supervisor`: configures the auto-mode supervisor that monitors agent progress and enforces timeouts. Keys: - - `model`: model ID to use for the supervisor process (defaults to the currently active model). - - `soft_timeout_minutes`: minutes before the supervisor issues a soft warning (default: 20). - - `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10). - - `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30). - -- `git`: configures SF's git behavior. All fields are optional — omit any to use defaults. Keys: - - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`. - - `push_branches`: boolean — push the milestone branch to the remote after commits. Default: `false`. - - `remote`: string — git remote name to push to. Default: `"origin"`. - - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `true`. - - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a worktree back to the integration branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `"auto"`. - - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content. - - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`. - - `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`. - - `isolation`: `"worktree"`, `"branch"`, or `"none"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root but creates a milestone branch (useful for submodule-heavy repos); `"none"` works directly on the current branch with no worktree or milestone branch (ideal for step-mode with hot reloads). Default: `"worktree"`. - - `manage_gitignore`: boolean — when `false`, SF will not touch `.gitignore` at all. Useful when your project has a strictly managed `.gitignore` and you don't want SF adding entries. Default: `true`. - - `worktree_post_create`: string — script to run after a worktree is created (both auto-mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none. - - `auto_pr`: boolean — automatically create a GitHub pull request after a milestone branch is merged. Requires `gh` CLI to be installed. Default: `false`. - - `pr_target_branch`: string — branch to target when `auto_pr` is enabled. Defaults to `main_branch` when omitted. - - **Deprecated:** `commit_docs` — no longer valid; `.gsd/` is always gitignored. Remove this setting. - - **Deprecated:** `merge_to_main` — no longer valid; milestone-level merge is always used. Remove this setting. - -- `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`. - -- `budget_ceiling`: number — maximum dollar amount to spend on auto-mode. When reached, behavior is controlled by `budget_enforcement`. Default: no limit. - -- `budget_enforcement`: `"warn"`, `"pause"`, or `"halt"` — action taken when `budget_ceiling` is reached. - - `warn` — log a warning but continue execution. - - `pause` — pause auto-mode and wait for user confirmation. - - `halt` — stop auto-mode immediately. - - Default: `"pause"`. - -- `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). - -- `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off. - -- `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys: - - `skip_research`: boolean — skip milestone-level research. Default: `false`. - - `reassess_after_slice`: boolean — run roadmap reassessment after each completed slice. Default: `true`. - - `skip_reassess`: boolean — force-disable roadmap reassessment even if `reassess_after_slice` is enabled. Default: `false`. - - `skip_slice_research`: boolean — skip per-slice research. Default: `false`. - -- `remote_questions`: route interactive questions to Slack/Discord for headless auto-mode. Keys: - - `channel`: `"slack"` or `"discord"` — channel type. - - `channel_id`: string or number — channel ID. - - `timeout_minutes`: number — question timeout in minutes (clamped 1-30). - - `poll_interval_seconds`: number — poll interval in seconds (clamped 2-30). - -- `notifications`: configures desktop notification behavior during auto-mode. Keys: - - `enabled`: boolean — master toggle for all notifications. Default: `true`. - - `on_complete`: boolean — notify when a unit completes. Default: `true`. - - `on_error`: boolean — notify on errors. Default: `true`. - - `on_budget`: boolean — notify when budget thresholds are reached. Default: `true`. - - `on_milestone`: boolean — notify when a milestone finishes. Default: `true`. - - `on_attention`: boolean — notify when manual attention is needed. Default: `true`. - -- `cmux`: configures cmux terminal integration when SF is running inside a cmux workspace. Keys: - - `enabled`: boolean — master toggle for cmux integration. Default: `false`. - - `notifications`: boolean — route desktop notifications through cmux. Default: `true` when enabled. - - `sidebar`: boolean — publish status, progress, and log metadata to the cmux sidebar. Default: `true` when enabled. - - `splits`: boolean — run supported subagent work in visible cmux splits. Default: `false`. - - `browser`: boolean — reserve the future browser integration flag. Default: `false`. - -- `dynamic_routing`: configures the dynamic model router that adjusts model selection based on task complexity. Keys: - - `enabled`: boolean — enable dynamic routing. Default: `false`. - - `tier_models`: object — model overrides per complexity tier. Keys: `light`, `standard`, `heavy`. Values are model ID strings. - - `escalate_on_failure`: boolean — escalate to a higher-tier model when the current one fails. Default: `true`. - - `budget_pressure`: boolean — downgrade model tier when budget is under pressure. Default: `true`. - - `cross_provider`: boolean — allow routing across different providers. Default: `true`. - - `hooks`: boolean — enable routing hooks. Default: `true`. - - `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`. - -- `uok`: orchestration kernel controls. Keys: - - `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`. - - `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`. - - Runtime override: set `SF_UOK_FORCE_LEGACY=1` (or `SF_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process. - - `gates.enabled`: boolean — route checks through the unified gate runner and persist `gate_runs`. - - `model_policy.enabled`: boolean — enforce policy filtering before model capability scoring. - - `execution_graph.enabled`: boolean — enable DAG scheduler facade/adapters for execution. - - `gitops.enabled`: boolean — persist turn-level git transaction records. - - `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode. - - `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata. - - `audit_envelope.enabled`: boolean — dual-write audit envelope events. - - `planning_flow.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. - -- `context_management`: configures context hygiene for auto-mode sessions. Keys: - - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`. - - `observation_mask_turns`: number — keep this many recent turns verbatim (1-50). Default: `8`. - - `compaction_threshold_percent`: number — trigger compaction at this % of context window (0.5-0.95). Lower values fire compaction earlier, reducing drift. Default: `0.70`. - - `tool_result_max_chars`: number — max chars per tool result in SF sessions (200-10000). Default: `800`. - -- `auto_visualize`: boolean — show a visualizer hint after each milestone completion in auto-mode. Default: `false`. - -- `auto_report`: boolean — generate an HTML report snapshot after each milestone completion. Default: `true`. - -- `search_provider`: `"brave"`, `"tavily"`, `"ollama"`, `"combosearch"`, `"native"`, or `"auto"` — selects the search backend for research phases. `"combosearch"` fans out across all configured custom search backends and merges the results. `"native"` forces Anthropic's built-in web search only; provider values force that backend and disable native search; `"auto"` uses the default heuristic. Default: `"auto"`. - -- `context_selection`: `"full"` or `"smart"` — controls how files are inlined into context. `"full"` inlines entire files; `"smart"` uses semantic chunking to include only the most relevant sections. Default is derived from `token_profile`. - -- `parallel`: configures parallel orchestration for running multiple slices concurrently. Keys: - - `enabled`: boolean — enable parallel execution. Default: `false`. - - `max_workers`: number — maximum concurrent workers (1-4). Default: `2`. - - `budget_ceiling`: number — optional per-parallel-run budget ceiling. - - `merge_strategy`: `"per-slice"` or `"per-milestone"` — when to merge worktree results back. Default: `"per-milestone"`. - - `auto_merge`: `"auto"`, `"confirm"`, or `"manual"` — merge behavior after completion. `"auto"` merges immediately; `"confirm"` asks first; `"manual"` leaves branches for you. Default: `"confirm"`. - - `worker_model`: string — optional model override for parallel milestone workers. When set, workers use this model (e.g. `"claude-haiku-4-5"`) instead of inheriting the coordinator's model. Useful for cost savings on execution-heavy milestones. - -- `verification_commands`: string[] — shell commands to run as verification after task execution (e.g., `["npm test", "npm run lint"]`). Commands run in order; if any fails, the task is marked as needing fixes. - -- `verification_auto_fix`: boolean — when `true`, automatically attempt to fix verification failures instead of just reporting them. Default: `false`. - -- `verification_max_retries`: number — maximum number of fix-and-retry cycles for verification failures. Default: `0` (no retries). - -- `uat_dispatch`: boolean — when `true`, enables UAT (User Acceptance Testing) dispatch mode. Default: `false`. - -- `post_unit_hooks`: array — hooks that fire after a unit completes. Each entry has: - - `name`: string — unique hook identifier. - - `after`: string[] — unit types that trigger this hook (e.g., `["execute-task"]`). - - `prompt`: string — prompt sent to the LLM. Supports `{milestoneId}`, `{sliceId}`, `{taskId}` substitutions. - - `max_cycles`: number — max times this hook fires per trigger (default: 1, max: 10). - - `model`: string — optional model override. - - `artifact`: string — expected output file name (relative to task/slice dir). Hook is skipped if file already exists (idempotent). - - `retry_on`: string — if this file is produced instead of the artifact, re-run the trigger unit then re-run hooks. - - `agent`: string — agent definition file to use for hook execution. - - `enabled`: boolean — toggle without removing (default: `true`). - -- `pre_dispatch_hooks`: array — hooks that fire before a unit is dispatched. Each entry has: - - `name`: string — unique hook identifier. - - `before`: string[] — unit types to intercept. - - `action`: `"modify"`, `"skip"`, or `"replace"` — what to do with the unit. - - `prepend`: string — text prepended to unit prompt (for `"modify"` action). - - `append`: string — text appended to unit prompt (for `"modify"` action). - - `prompt`: string — replacement prompt (for `"replace"` action; required when action is `"replace"`). - - `unit_type`: string — override unit type label (for `"replace"` action). - - `skip_if`: string — for `"skip"` action: only skip if this file exists (relative to unit dir). - - `model`: string — optional model override when this hook fires. - - `enabled`: boolean — toggle without removing (default: `true`). - - **Action validation:** - - `"modify"` requires at least one of `prepend` or `append`. - - `"replace"` requires `prompt`. - - `"skip"` is valid with no additional fields. - - **Known unit types for `before`/`after`:** `research-milestone`, `plan-milestone`, `research-slice`, `plan-slice`, `execute-task`, `complete-slice`, `replan-slice`, `reassess-roadmap`, `run-uat`. - -- `experimental`: opt-in experimental features. All features here are **off by default** — you must explicitly set each one to `true` to enable it. Features in this block may change or be removed without a deprecation cycle while in experimental status. Keys: - - `rtk`: boolean — enable RTK (Real-Time Kompression) shell-command compression. When enabled, SF wraps shell commands through the RTK binary to reduce token usage during command execution. RTK is downloaded automatically on first use if not already installed. **Default: `false`** (opt-in required). Set `SF_RTK_DISABLED=1` in the environment to force-disable regardless of this preference. - ---- - -## Best Practices - -- Keep `always_use_skills` short. -- Use `skill_rules` for situational routing, not broad personality preferences. -- Prefer skill names for stable built-in skills. -- Prefer absolute paths for local personal skills. -- **Omit fields you don't need** — empty arrays add noise with no effect. - ---- - -## Workflow Mode Examples - -**Solo developer — auto-push, simple IDs:** - -```yaml ---- -version: 1 -mode: solo ---- -``` - -Equivalent to setting `git.auto_push: true`, `git.push_branches: false`, `git.pre_merge_check: false`, `git.merge_strategy: squash`, `git.isolation: worktree`, `unique_milestone_ids: false`. - -**Team — unique IDs, push branches, pre-merge checks:** - -```yaml ---- -version: 1 -mode: team ---- -``` - -Equivalent to setting `git.auto_push: false`, `git.push_branches: true`, `git.pre_merge_check: true`, `git.merge_strategy: squash`, `git.isolation: worktree`, `unique_milestone_ids: true`. - -**Mode with overrides — team mode but with auto-push:** - -```yaml ---- -version: 1 -mode: team -git: - auto_push: true ---- -``` - -Gets all team defaults except `auto_push`, which is explicitly overridden to `true`. Any explicit setting always wins over the mode default. - ---- - -## Minimal Example - -The cleanest preferences file only specifies what you actually want: - -```yaml ---- -version: 1 -always_use_skills: - - debug-like-expert -skill_discovery: suggest -models: - planning: claude-opus-4-6 - execution: claude-sonnet-4-6 ---- -``` - -Everything else uses defaults. No `prefer_skills: []`, no `avoid_skills: []`, no `auto_supervisor: {}` — those are just noise. - ---- - -## Models Example - -```yaml ---- -version: 1 -models: - research: claude-sonnet-4-6 - planning: claude-opus-4-6 - execution: claude-sonnet-4-6 - completion: claude-sonnet-4-6 ---- -``` - -Opus for planning (where architectural decisions matter most), Sonnet for everything else (faster, cheaper). Omit any key to use the currently selected model. - -## Models with Fallbacks Example - -```yaml ---- -version: 1 -models: - research: - model: openrouter/deepseek/deepseek-r1 - fallbacks: - - openrouter/minimax/minimax-m2.5 - planning: - model: claude-opus-4-6 - fallbacks: - - openrouter/z-ai/glm-5 - - openrouter/moonshotai/kimi-k2.5 - execution: - model: openrouter/z-ai/glm-5 - fallbacks: - - openrouter/minimax/minimax-m2.5 - completion: openrouter/minimax/minimax-m2.5 ---- -``` - -When a model fails to switch (provider unavailable, rate limited, credits exhausted), SF automatically tries the next model in the `fallbacks` list. This ensures auto-mode continues even when your preferred provider hits limits. - -## Provider Targeting - -When the same model ID exists across multiple providers (e.g., `claude-sonnet-4-6` on both Anthropic and Bedrock), use the `provider/model` format or the `provider` field to target a specific one: - -```yaml ---- -version: 1 -models: - # String format: provider/model - research: bedrock/claude-sonnet-4-6 - planning: anthropic/claude-opus-4-6 - - # Object format: explicit provider field - execution: - model: claude-sonnet-4-6 - provider: bedrock - fallbacks: - - anthropic/claude-sonnet-4-6 ---- -``` - -If you use a bare model ID (no provider prefix) and it exists in multiple providers, SF will warn you and resolve to the first available match. Use `provider/model` format to avoid ambiguity. - -**Cost-optimized example** — use cheap models with expensive ones as fallback for critical phases: - -```yaml ---- -version: 1 -models: - research: openrouter/deepseek/deepseek-r1 # $0.28/$0.42 per 1M tokens - planning: - model: claude-opus-4-6 # $5/$25 — best for architecture - fallbacks: - - openrouter/z-ai/glm-5 # $1/$3.20 — strong alternative - execution: openrouter/minimax/minimax-m2.5 # $0.30/$1.20 — cheapest quality - completion: openrouter/minimax/minimax-m2.5 ---- -``` - ---- - -## Example Variations - -**Minimal — always load a UAT skill and route Clerk tasks:** - -```yaml ---- -version: 1 -always_use_skills: - - /Users/you/.claude/skills/verify-uat -skill_rules: - - when: finishing implementation and human judgment matters - use: - - /Users/you/.claude/skills/verify-uat ---- -``` - -**Richer routing — prefer cleanup and authentication skills:** - -```yaml ---- -version: 1 -prefer_skills: - - commit-ignore -skill_rules: - - when: task involves Clerk authentication - use: - - clerk - - clerk-setup - - when: the user is looking for installable capability rather than implementation - prefer: - - find-skills ---- -``` - ---- - -## Git Preferences Example - -```yaml ---- -version: 1 -git: - auto_push: true - push_branches: true - remote: origin - snapshots: true - pre_merge_check: auto - commit_type: feat ---- -``` - -All git fields are optional. Omit any field to use the default behavior. Project-level preferences override global preferences on a per-field basis. - ---- - -## Budget & Cost Control Example - -```yaml ---- -version: 1 -budget_ceiling: 10.00 -budget_enforcement: pause -context_pause_threshold: 80 ---- -``` - -Sets a $10 budget ceiling. Auto-mode pauses when the ceiling is reached. Context window pauses at 80% usage for checkpointing. - ---- - -## Notifications Example - -```yaml ---- -version: 1 -notifications: - enabled: true - on_complete: false - on_error: true - on_budget: true - on_milestone: true - on_attention: true ---- -``` - -Disables per-unit completion notifications (noisy in long runs) while keeping error, budget, milestone, and attention notifications enabled. - ---- - -## cmux Example - -```yaml ---- -version: 1 -cmux: - enabled: true - notifications: true - sidebar: true - splits: true - browser: false ---- -``` - -Enables cmux-aware notifications, sidebar metadata, and visible subagent splits when SF is running inside a cmux terminal. - ---- - -## Post-Unit Hooks Example - -```yaml ---- -version: 1 -post_unit_hooks: - - name: code-review - after: - - execute-task - prompt: "Review the code changes in {sliceId}/{taskId} for quality, security, and test coverage." - max_cycles: 1 - artifact: REVIEW.md ---- -``` - -Runs an automated code review after each task execution. Skips if `REVIEW.md` already exists (idempotent). - ---- - -## Pre-Dispatch Hooks Examples - -**Modify — inject instructions before every task:** - -```yaml ---- -version: 1 -pre_dispatch_hooks: - - name: enforce-standards - before: - - execute-task - action: modify - prepend: "Follow our TypeScript coding standards and always run linting." ---- -``` - -**Skip — skip per-slice research when a research file already exists:** - -```yaml ---- -version: 1 -pre_dispatch_hooks: - - name: skip-existing-research - before: - - research-slice - action: skip - skip_if: RESEARCH.md ---- -``` - -**Replace — substitute a custom prompt for task execution:** - -```yaml ---- -version: 1 -pre_dispatch_hooks: - - name: tdd-execute - before: - - execute-task - action: replace - prompt: "Implement the task using strict TDD. Write failing tests first, then implement, then refactor." - model: claude-opus-4-6 ---- -``` - ---- - -## Token Profile & Phases Example - -```yaml ---- -version: 1 -token_profile: budget -phases: - skip_research: true - skip_reassess: true - skip_slice_research: false ---- -``` - -Uses the `budget` profile to minimize token usage, with explicit override to keep slice-level research enabled. - ---- - -## Remote Questions Example - -```yaml ---- -version: 1 -remote_questions: - channel: slack - channel_id: "C0123456789" - timeout_minutes: 15 - poll_interval_seconds: 10 ---- -``` - -Routes interactive questions to a Slack channel for headless auto-mode sessions. Questions time out after 15 minutes if unanswered. - ---- - -## Dynamic Routing Example - -```yaml ---- -version: 1 -dynamic_routing: - enabled: true - tier_models: - light: openrouter/minimax/minimax-m2.5 - standard: claude-sonnet-4-6 - heavy: claude-opus-4-6 - escalate_on_failure: true - budget_pressure: true ---- -``` - -Automatically selects model tier based on task complexity. Simple tasks use the `light` model, complex tasks escalate to `heavy`. Under budget pressure, tasks are routed to cheaper tiers. - ---- - -## Parallel Execution Example - -```yaml ---- -version: 1 -parallel: - enabled: true - max_workers: 3 - merge_strategy: per-milestone - auto_merge: confirm ---- -``` - -Runs up to 3 slices concurrently in separate worktrees. Results are merged per-milestone with user confirmation. - ---- - -## Verification Example - -```yaml ---- -version: 1 -verification_commands: - - npm test - - npm run lint - - npm run typecheck -verification_auto_fix: true -verification_max_retries: 2 ---- -``` - -Runs test, lint, and typecheck after each task. On failure, auto-fix is attempted up to 2 times before reporting the issue. - -## Experimental Features Example - -```yaml ---- -version: 1 -experimental: - rtk: true ---- -``` - -Opts in to RTK shell-command compression. RTK is downloaded automatically on first use. Set `SF_RTK_DISABLED=1` to force-disable at the environment level regardless of this setting. diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts deleted file mode 100644 index d9a26e66c..000000000 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-exports for backward compatibility -export { checkGitHealth } from "./doctor-git-checks.js"; -export { checkRuntimeHealth } from "./doctor-runtime-checks.js"; -export { checkGlobalHealth } from "./doctor-global-checks.js"; -export { checkEngineHealth } from "./doctor-engine-checks.js"; diff --git a/src/resources/extensions/gsd/doctor-engine-checks.ts b/src/resources/extensions/gsd/doctor-engine-checks.ts deleted file mode 100644 index e7fc57540..000000000 --- a/src/resources/extensions/gsd/doctor-engine-checks.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { existsSync, statSync } from "node:fs"; -import { join } from "node:path"; - -import type { DoctorIssue } from "./doctor-types.js"; -import { isDbAvailable, _getAdapter } from "./gsd-db.js"; -import { resolveMilestoneFile } from "./paths.js"; -import { deriveState } from "./state.js"; -import { readEvents } from "./workflow-events.js"; -import { renderAllProjections } from "./workflow-projections.js"; - -export async function checkEngineHealth( - basePath: string, - issues: DoctorIssue[], - fixesApplied: string[], -): Promise<void> { - const dbPath = join(basePath, ".gsd", "gsd.db"); - - if (!isDbAvailable() && existsSync(dbPath)) { - issues.push({ - severity: "warning", - code: "db_unavailable", - scope: "project", - unitId: "project", - message: "Database unavailable — using filesystem state derivation (degraded mode). State queries may be slower and less reliable.", - file: ".gsd/gsd.db", - fixable: false, - }); - } - - // ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ── - try { - if (isDbAvailable()) { - const adapter = _getAdapter()!; - - // a. Orphaned tasks (task.slice_id points to non-existent slice) - try { - const orphanedTasks = adapter - .prepare( - `SELECT t.id, t.slice_id, t.milestone_id - FROM tasks t - LEFT JOIN slices s ON t.milestone_id = s.milestone_id AND t.slice_id = s.id - WHERE s.id IS NULL`, - ) - .all() as Array<{ id: string; slice_id: string; milestone_id: string }>; - - for (const row of orphanedTasks) { - issues.push({ - severity: "error", - code: "db_orphaned_task", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`, - fixable: false, - }); - } - } catch { - // Non-fatal — orphaned task check failed - } - - // b. Orphaned slices (slice.milestone_id points to non-existent milestone) - try { - const orphanedSlices = adapter - .prepare( - `SELECT s.id, s.milestone_id - FROM slices s - LEFT JOIN milestones m ON s.milestone_id = m.id - WHERE m.id IS NULL`, - ) - .all() as Array<{ id: string; milestone_id: string }>; - - for (const row of orphanedSlices) { - issues.push({ - severity: "error", - code: "db_orphaned_slice", - scope: "slice", - unitId: `${row.milestone_id}/${row.id}`, - message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`, - fixable: false, - }); - } - } catch { - // Non-fatal — orphaned slice check failed - } - - // c. Tasks marked complete without summaries - try { - const doneTasks = adapter - .prepare( - `SELECT id, slice_id, milestone_id FROM tasks - WHERE status = 'done' AND (summary IS NULL OR summary = '')`, - ) - .all() as Array<{ id: string; slice_id: string; milestone_id: string }>; - - for (const row of doneTasks) { - issues.push({ - severity: "warning", - code: "db_done_task_no_summary", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Task ${row.id} is marked done but has no summary in the database`, - fixable: false, - }); - } - } catch { - // Non-fatal — done-task-no-summary check failed - } - - // d. Duplicate entity IDs (safety check) - try { - const dupMilestones = adapter - .prepare("SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1") - .all() as Array<{ id: string; cnt: number }>; - for (const row of dupMilestones) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "milestone", - unitId: row.id, - message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`, - fixable: false, - }); - } - - const dupSlices = adapter - .prepare("SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1") - .all() as Array<{ id: string; milestone_id: string; cnt: number }>; - for (const row of dupSlices) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "slice", - unitId: `${row.milestone_id}/${row.id}`, - message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`, - fixable: false, - }); - } - - const dupTasks = adapter - .prepare("SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1") - .all() as Array<{ id: string; slice_id: string; milestone_id: string; cnt: number }>; - for (const row of dupTasks) { - issues.push({ - severity: "error", - code: "db_duplicate_id", - scope: "task", - unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, - message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`, - fixable: false, - }); - } - } catch { - // Non-fatal — duplicate ID check failed - } - } - } catch { - // Non-fatal — DB constraint checks failed entirely - } - - // ── Projection drift detection ────────────────────────────────────────── - // If the DB is available, check whether markdown projections are stale - // relative to the event log and re-render them. - try { - if (isDbAvailable()) { - const eventLogPath = join(basePath, ".gsd", "event-log.jsonl"); - const events = readEvents(eventLogPath); - if (events.length > 0) { - const lastEventTs = new Date(events[events.length - 1]!.ts).getTime(); - const state = await deriveState(basePath); - for (const milestone of state.registry) { - if (milestone.status === "complete") continue; - const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) { - try { - await renderAllProjections(basePath, milestone.id); - fixesApplied.push(`re-rendered missing projections for ${milestone.id}`); - } catch { - // Non-fatal — projection re-render failed - } - continue; - } - const projectionMtime = statSync(roadmapPath).mtimeMs; - if (lastEventTs > projectionMtime) { - try { - await renderAllProjections(basePath, milestone.id); - fixesApplied.push(`re-rendered stale projections for ${milestone.id}`); - } catch { - // Non-fatal — projection re-render failed - } - } - } - } - } - } catch { - // Non-fatal — projection drift check must never block doctor - } -} diff --git a/src/resources/extensions/gsd/doctor-environment.ts b/src/resources/extensions/gsd/doctor-environment.ts deleted file mode 100644 index faffb9609..000000000 --- a/src/resources/extensions/gsd/doctor-environment.ts +++ /dev/null @@ -1,642 +0,0 @@ -/** - * SF Doctor — Environment Health Checks (#1221) - * - * Deterministic checks for environment readiness that prevent the model - * from spinning its wheels on missing tools, port conflicts, stale - * dependencies, and other infrastructure issues. - * - * These checks complement the existing git/runtime health checks and - * integrate into the doctor pipeline via checkEnvironmentHealth(). - */ - -import { existsSync, readFileSync, statSync } from "node:fs"; -import { execSync } from "node:child_process"; -import { join } from "node:path"; - -import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; - -// ── Types ────────────────────────────────────────────────────────────────── - -export interface EnvironmentCheckResult { - name: string; - status: "ok" | "warning" | "error"; - message: string; - detail?: string; -} - -// ── Constants ────────────────────────────────────────────────────────────── - -/** Default dev server ports to scan for conflicts. */ -const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888]; - -/** Minimum free disk space in bytes (500MB). */ -const MIN_DISK_BYTES = 500 * 1024 * 1024; - -/** Timeout for external commands (ms). */ -const CMD_TIMEOUT = 5_000; - -// ── Helpers ──────────────────────────────────────────────────────────────── - -/** Worktree sentinel — path segment that marks an auto-worktree directory. */ -const WORKTREE_PATH_SEGMENT = `${join(".gsd", "worktrees")}/`; - -/** - * Resolve the project root when running inside a `.gsd/worktrees/<name>/` - * auto-worktree. Returns `null` if not in a worktree. - * - * Detection order: - * 1. `SF_WORKTREE` env var (set by the worktree launcher) - * 2. `.gsd/worktrees/` segment in basePath - */ -function resolveWorktreeProjectRoot(basePath: string): string | null { - const envRoot = process.env.SF_WORKTREE; - if (envRoot) return envRoot; - - const normalised = basePath.replace(/\\/g, "/"); - const idx = normalised.indexOf(WORKTREE_PATH_SEGMENT.replace(/\\/g, "/")); - if (idx === -1) return null; - - // Everything before `.gsd/worktrees/` is the project root - return basePath.slice(0, idx); -} - -function tryExec(cmd: string, cwd: string): string | null { - try { - return execSync(cmd, { - cwd, - timeout: CMD_TIMEOUT, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - } catch { - return null; - } -} - -function commandExists(name: string, cwd: string): boolean { - const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`; - return tryExec(whichCmd, cwd) !== null; -} - -// ── Individual Checks ────────────────────────────────────────────────────── - -/** - * Check that Node.js version meets the project's engines requirement. - */ -function checkNodeVersion(basePath: string): EnvironmentCheckResult | null { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) return null; - - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const required = pkg.engines?.node; - if (!required) return null; - - const currentVersion = tryExec("node --version", basePath); - if (!currentVersion) { - return { name: "node_version", status: "error", message: "Node.js not found in PATH" }; - } - - // Parse semver requirement (handles >=X.Y.Z format) - const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/); - if (!reqMatch) return null; - - const reqMajor = parseInt(reqMatch[1], 10); - const reqMinor = parseInt(reqMatch[2] ?? "0", 10); - - const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/); - if (!curMatch) return null; - - const curMajor = parseInt(curMatch[1], 10); - const curMinor = parseInt(curMatch[2], 10); - - if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) { - return { - name: "node_version", - status: "warning", - message: `Node.js ${currentVersion} does not meet requirement "${required}"`, - detail: `Current: ${currentVersion}, Required: ${required}`, - }; - } - - return { name: "node_version", status: "ok", message: `Node.js ${currentVersion}` }; - } catch { - return null; - } -} - -/** - * Check if node_modules exists and is not stale vs the lockfile. - */ -function checkDependenciesInstalled(basePath: string): EnvironmentCheckResult | null { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) return null; - - const nodeModules = join(basePath, "node_modules"); - if (!existsSync(nodeModules)) { - // In auto-worktrees node_modules is absent by design — the worktree - // symlinks to (or expects) the project root's copy. Fall back to - // checking the project root before reporting an error (#2303). - const projectRoot = resolveWorktreeProjectRoot(basePath); - if (projectRoot && existsSync(join(projectRoot, "node_modules"))) { - return { name: "dependencies", status: "ok", message: "Dependencies installed (project root)" }; - } - - return { - name: "dependencies", - status: "error", - message: "node_modules missing — run npm install", - }; - } - - // Check if lockfile is newer than the last install. - // - // Each package manager writes a metadata marker inside node_modules on - // every install. Comparing the lockfile mtime against the marker is - // reliable; comparing against the node_modules *directory* mtime is not, - // because directory mtime only changes when entries are added or removed - // — not when files inside it are updated. (#1974) - const lockfiles: Array<{ lock: string; markers: string[] }> = [ - { lock: "package-lock.json", markers: ["node_modules/.package-lock.json"] }, - { lock: "yarn.lock", markers: ["node_modules/.yarn-integrity"] }, - { lock: "pnpm-lock.yaml", markers: ["node_modules/.modules.yaml"] }, - ]; - - for (const { lock, markers } of lockfiles) { - const lockPath = join(basePath, lock); - if (!existsSync(lockPath)) continue; - - try { - const lockMtime = statSync(lockPath).mtimeMs; - - // Prefer the package manager's marker file; fall back to directory mtime - // only when no marker exists (e.g., manually created node_modules). - let installMtime = 0; - for (const marker of markers) { - const markerPath = join(basePath, marker); - if (existsSync(markerPath)) { - installMtime = Math.max(installMtime, statSync(markerPath).mtimeMs); - } - } - if (installMtime === 0) { - installMtime = statSync(nodeModules).mtimeMs; - } - - if (lockMtime > installMtime) { - return { - name: "dependencies", - status: "warning", - message: `${lock} is newer than node_modules — dependencies may be stale`, - detail: `Run npm install / yarn / pnpm install to update`, - }; - } - } catch { - // stat failed — skip - } - } - - return { name: "dependencies", status: "ok", message: "Dependencies installed" }; -} - -/** - * Check for .env.example files without corresponding .env files. - */ -function checkEnvFiles(basePath: string): EnvironmentCheckResult | null { - const examplePath = join(basePath, ".env.example"); - if (!existsSync(examplePath)) return null; - - const envPath = join(basePath, ".env"); - const envLocalPath = join(basePath, ".env.local"); - - if (!existsSync(envPath) && !existsSync(envLocalPath)) { - return { - name: "env_file", - status: "warning", - message: ".env.example exists but no .env or .env.local found", - detail: "Copy .env.example to .env and fill in values", - }; - } - - return { name: "env_file", status: "ok", message: "Environment file present" }; -} - -/** - * Check for port conflicts on common dev server ports. - * Only checks ports that appear in package.json scripts. - */ -function checkPortConflicts(basePath: string): EnvironmentCheckResult[] { - // Only run on macOS/Linux — lsof is not available on Windows - if (process.platform === "win32") return []; - - const results: EnvironmentCheckResult[] = []; - - // Try to detect ports from package.json scripts - const portsToCheck = new Set<number>(); - const pkgPath = join(basePath, "package.json"); - - if (!existsSync(pkgPath)) { - // No package.json — this isn't a Node.js project. Skip port checks - // entirely to avoid false positives from system services (e.g., macOS - // AirPlay Receiver on port 5000). (#1381) - return []; - } - - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const scripts = pkg.scripts ?? {}; - const scriptText = Object.values(scripts).join(" "); - - // Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns - const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi); - for (const m of portMatches) { - const port = parseInt(m[1], 10); - if (port >= 1024 && port <= 65535) portsToCheck.add(port); - } - } catch { - // parse failed — skip port checks rather than using defaults - return []; - } - - // If no ports found in scripts, check common defaults. - // Filter out port 5000 on macOS — AirPlay Receiver uses it by default (#1381). - if (portsToCheck.size === 0) { - for (const p of DEFAULT_DEV_PORTS) { - if (p === 5000 && process.platform === "darwin") continue; - portsToCheck.add(p); - } - } - - for (const port of portsToCheck) { - const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath); - if (result && result.length > 0) { - // Get process name - const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -Fp | head -2`, basePath); - const processName = nameResult?.match(/p(\d+)\n?c?(.+)?/)?.[2] ?? "unknown"; - - results.push({ - name: "port_conflict", - status: "warning", - message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`, - detail: `Kill the process or use a different port`, - }); - } - } - - return results; -} - -/** - * Check available disk space on the working directory partition. - */ -function checkDiskSpace(basePath: string): EnvironmentCheckResult | null { - // Only run on macOS/Linux - if (process.platform === "win32") return null; - - const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath); - if (!dfOutput) return null; - - try { - // df output: filesystem blocks used avail capacity mount - const parts = dfOutput.split(/\s+/); - const availKB = parseInt(parts[3], 10); - if (isNaN(availKB)) return null; - - const availBytes = availKB * 1024; - const availMB = Math.round(availBytes / (1024 * 1024)); - const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1); - - if (availBytes < MIN_DISK_BYTES) { - return { - name: "disk_space", - status: "error", - message: `Low disk space: ${availMB}MB free`, - detail: `Free up space — builds and git operations may fail`, - }; - } - - if (availBytes < MIN_DISK_BYTES * 4) { - return { - name: "disk_space", - status: "warning", - message: `Disk space getting low: ${availGB}GB free`, - }; - } - - return { name: "disk_space", status: "ok", message: `${availGB}GB free` }; - } catch { - return null; - } -} - -/** - * Check if Docker is available when project has a Dockerfile. - */ -function checkDocker(basePath: string): EnvironmentCheckResult | null { - const hasDockerfile = existsSync(join(basePath, "Dockerfile")) || - existsSync(join(basePath, "docker-compose.yml")) || - existsSync(join(basePath, "docker-compose.yaml")) || - existsSync(join(basePath, "compose.yml")) || - existsSync(join(basePath, "compose.yaml")); - - if (!hasDockerfile) return null; - - if (!commandExists("docker", basePath)) { - return { - name: "docker", - status: "warning", - message: "Project has Docker files but docker is not installed", - }; - } - - const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath); - if (!info) { - return { - name: "docker", - status: "warning", - message: "Docker is installed but daemon is not running", - detail: "Start Docker Desktop or the docker daemon", - }; - } - - return { name: "docker", status: "ok", message: `Docker ${info}` }; -} - -/** - * Check for common project tools that should be available. - */ -function checkProjectTools(basePath: string): EnvironmentCheckResult[] { - const results: EnvironmentCheckResult[] = []; - const pkgPath = join(basePath, "package.json"); - - if (!existsSync(pkgPath)) return results; - - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const allDeps = { - ...(pkg.dependencies ?? {}), - ...(pkg.devDependencies ?? {}), - }; - - // Check for package manager - const packageManager = pkg.packageManager; - if (packageManager) { - const managerName = packageManager.split("@")[0]; - if (managerName && managerName !== "npm" && !commandExists(managerName, basePath)) { - results.push({ - name: "package_manager", - status: "warning", - message: `Project requires ${managerName} but it's not installed`, - detail: `Install with: npm install -g ${managerName}`, - }); - } - } - - // Check for TypeScript if it's a dependency - if (allDeps["typescript"] && !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) { - results.push({ - name: "typescript", - status: "warning", - message: "TypeScript is a dependency but tsc is not available (run npm install)", - }); - } - - // Check for Python if pyproject.toml or requirements.txt exists - if (existsSync(join(basePath, "pyproject.toml")) || existsSync(join(basePath, "requirements.txt"))) { - if (!commandExists("python3", basePath) && !commandExists("python", basePath)) { - results.push({ - name: "python", - status: "warning", - message: "Project has Python config but python is not installed", - }); - } - } - - // Check for Rust if Cargo.toml exists - if (existsSync(join(basePath, "Cargo.toml"))) { - if (!commandExists("cargo", basePath)) { - results.push({ - name: "cargo", - status: "warning", - message: "Project has Cargo.toml but cargo is not installed", - }); - } - } - - // Check for Go if go.mod exists - if (existsSync(join(basePath, "go.mod"))) { - if (!commandExists("go", basePath)) { - results.push({ - name: "go", - status: "warning", - message: "Project has go.mod but go is not installed", - }); - } - } - } catch { - // parse failed — skip - } - - return results; -} - -/** - * Check git remote reachability. - */ -function checkGitRemote(basePath: string): EnvironmentCheckResult | null { - // Only check if it's a git repo with a remote - const remote = tryExec("git remote get-url origin", basePath); - if (!remote) return null; - - // Quick connectivity check with short timeout - const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath); - if (result === null) { - return { - name: "git_remote", - status: "warning", - message: "Git remote 'origin' is unreachable", - detail: `Remote: ${remote}`, - }; - } - - return { name: "git_remote", status: "ok", message: "Git remote reachable" }; -} - -/** - * Check if the project build passes (opt-in slow check, use --build flag). - * Runs npm run build and reports failure as env_build. - */ -function checkBuildHealth(basePath: string): EnvironmentCheckResult | null { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) return null; - - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const buildScript = pkg.scripts?.build; - if (!buildScript) return null; - - const result = tryExec("npm run build 2>&1", basePath); - if (result === null) { - return { - name: "build", - status: "error", - message: "Build failed — npm run build exited non-zero", - detail: "Fix build errors before dispatching work", - }; - } - return { name: "build", status: "ok", message: "Build passes" }; - } catch { - return null; - } -} - -/** - * Check if tests pass (opt-in slow check, use --test flag). - * Runs npm test and reports failures as env_test. - */ -function checkTestHealth(basePath: string): EnvironmentCheckResult | null { - const pkgPath = join(basePath, "package.json"); - if (!existsSync(pkgPath)) return null; - - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const testScript = pkg.scripts?.test; - // Skip if no test script or the default placeholder - if (!testScript || testScript.includes("no test specified")) return null; - - const result = tryExec("npm test 2>&1", basePath); - if (result === null) { - return { - name: "test", - status: "warning", - message: "Tests failing — npm test exited non-zero", - detail: "Fix failing tests before shipping", - }; - } - return { name: "test", status: "ok", message: "Tests pass" }; - } catch { - return null; - } -} - -// ── Public API ───────────────────────────────────────────────────────────── - -/** - * Run all environment health checks. Returns structured results for - * integration with the doctor pipeline. - */ -export function runEnvironmentChecks(basePath: string): EnvironmentCheckResult[] { - const results: EnvironmentCheckResult[] = []; - - const nodeCheck = checkNodeVersion(basePath); - if (nodeCheck) results.push(nodeCheck); - - const depsCheck = checkDependenciesInstalled(basePath); - if (depsCheck) results.push(depsCheck); - - const envCheck = checkEnvFiles(basePath); - if (envCheck) results.push(envCheck); - - results.push(...checkPortConflicts(basePath)); - - const diskCheck = checkDiskSpace(basePath); - if (diskCheck) results.push(diskCheck); - - const dockerCheck = checkDocker(basePath); - if (dockerCheck) results.push(dockerCheck); - - results.push(...checkProjectTools(basePath)); - - // Git remote check can be slow — only run on explicit doctor invocation - // (not on pre-dispatch gate) - - return results; -} - -/** - * Run environment checks with git remote check included. - * Use this for explicit /gsd doctor invocations, not pre-dispatch gates. - */ -export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResult[] { - const results = runEnvironmentChecks(basePath); - - const remoteCheck = checkGitRemote(basePath); - if (remoteCheck) results.push(remoteCheck); - - return results; -} - -/** - * Run slow opt-in checks (build and/or test). - * These are never run on the pre-dispatch gate — only on explicit /gsd doctor --build/--test. - */ -export function runSlowEnvironmentChecks( - basePath: string, - options?: { includeBuild?: boolean; includeTests?: boolean }, -): EnvironmentCheckResult[] { - const results: EnvironmentCheckResult[] = []; - if (options?.includeBuild) { - const buildCheck = checkBuildHealth(basePath); - if (buildCheck) results.push(buildCheck); - } - if (options?.includeTests) { - const testCheck = checkTestHealth(basePath); - if (testCheck) results.push(testCheck); - } - return results; -} - -/** - * Convert environment check results to DoctorIssue format for the doctor pipeline. - */ -export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult[]): DoctorIssue[] { - return results - .filter(r => r.status !== "ok") - .map(r => ({ - severity: r.status === "error" ? "error" as const : "warning" as const, - code: `env_${r.name}` as DoctorIssueCode, - scope: "project" as const, - unitId: "environment", - message: r.detail ? `${r.message} — ${r.detail}` : r.message, - fixable: false, - })); -} - -/** - * Integration point for the doctor pipeline. Runs environment checks - * and appends issues to the provided array. - */ -export async function checkEnvironmentHealth( - basePath: string, - issues: DoctorIssue[], - options?: { includeRemote?: boolean; includeBuild?: boolean; includeTests?: boolean }, -): Promise<void> { - const results = options?.includeRemote - ? runFullEnvironmentChecks(basePath) - : runEnvironmentChecks(basePath); - - if (options?.includeBuild || options?.includeTests) { - results.push(...runSlowEnvironmentChecks(basePath, options)); - } - - issues.push(...environmentResultsToDoctorIssues(results)); -} - -/** - * Format environment check results for display. - */ -export function formatEnvironmentReport(results: EnvironmentCheckResult[]): string { - if (results.length === 0) return "No environment checks applicable."; - - const lines: string[] = []; - lines.push("Environment Health:"); - - for (const r of results) { - const icon = r.status === "ok" ? "\u2705" : r.status === "warning" ? "\u26A0\uFE0F" : "\uD83D\uDED1"; - lines.push(` ${icon} ${r.message}`); - if (r.detail && r.status !== "ok") { - lines.push(` ${r.detail}`); - } - } - - return lines.join("\n"); -} diff --git a/src/resources/extensions/gsd/doctor-format.ts b/src/resources/extensions/gsd/doctor-format.ts deleted file mode 100644 index 95ea3ca82..000000000 --- a/src/resources/extensions/gsd/doctor-format.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { DoctorIssue, DoctorIssueCode, DoctorReport, DoctorSummary } from "./doctor-types.js"; - -function matchesScope(unitId: string, scope?: string): boolean { - if (!scope) return true; - if (unitId === "project" || unitId === "environment") return true; - return unitId === scope || unitId.startsWith(`${scope}/`) || unitId.startsWith(`${scope}`); -} - -export function summarizeDoctorIssues(issues: DoctorIssue[]): DoctorSummary { - const errors = issues.filter(issue => issue.severity === "error").length; - const warnings = issues.filter(issue => issue.severity === "warning").length; - const infos = issues.filter(issue => issue.severity === "info").length; - const fixable = issues.filter(issue => issue.fixable).length; - const byCodeMap = new Map<DoctorIssueCode, number>(); - for (const issue of issues) { - byCodeMap.set(issue.code, (byCodeMap.get(issue.code) ?? 0) + 1); - } - const byCode = [...byCodeMap.entries()] - .map(([code, count]) => ({ code, count })) - .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); - return { total: issues.length, errors, warnings, infos, fixable, byCode }; -} - -export function filterDoctorIssues(issues: DoctorIssue[], options?: { scope?: string; includeWarnings?: boolean; includeHistorical?: boolean }): DoctorIssue[] { - let filtered = issues; - if (options?.scope) filtered = filtered.filter(issue => matchesScope(issue.unitId, options.scope)); - if (!options?.includeWarnings) filtered = filtered.filter(issue => issue.severity === "error"); - return filtered; -} - -export function formatDoctorReport( - report: DoctorReport, - options?: { scope?: string; includeWarnings?: boolean; maxIssues?: number; title?: string }, -): string { - const scopedIssues = filterDoctorIssues(report.issues, { - scope: options?.scope, - includeWarnings: options?.includeWarnings ?? true, - }); - const summary = summarizeDoctorIssues(scopedIssues); - const maxIssues = options?.maxIssues ?? 12; - const lines: string[] = []; - lines.push(options?.title ?? (summary.errors > 0 ? "SF doctor found blocking issues." : "SF doctor report.")); - lines.push(`Scope: ${options?.scope ?? "all milestones"}`); - lines.push(`Issues: ${summary.total} total · ${summary.errors} error(s) · ${summary.warnings} warning(s) · ${summary.fixable} fixable`); - - if (summary.byCode.length > 0) { - lines.push("Top issue types:"); - for (const item of summary.byCode.slice(0, 5)) { - lines.push(`- ${item.code}: ${item.count}`); - } - } - - if (scopedIssues.length > 0) { - lines.push("Priority issues:"); - for (const issue of scopedIssues.slice(0, maxIssues)) { - const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO"; - lines.push(`- [${prefix}] ${issue.unitId}: ${issue.message}${issue.file ? ` (${issue.file})` : ""}`); - } - if (scopedIssues.length > maxIssues) { - lines.push(`- ...and ${scopedIssues.length - maxIssues} more in scope`); - } - } - - if (report.fixesApplied.length > 0) { - lines.push("Fixes applied:"); - for (const fix of report.fixesApplied.slice(0, maxIssues)) lines.push(`- ${fix}`); - if (report.fixesApplied.length > maxIssues) lines.push(`- ...and ${report.fixesApplied.length - maxIssues} more`); - } - - return lines.join("\n"); -} - -export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string { - if (issues.length === 0) return "- No remaining issues in scope."; - return issues.map(issue => { - const prefix = issue.severity === "error" ? "ERROR" : issue.severity === "warning" ? "WARN" : "INFO"; - return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`; - }).join("\n"); -} - -/** - * Serialize a doctor report to JSON — suitable for CI/tooling integration. - * Usage: /gsd doctor --json - */ -export function formatDoctorReportJson(report: DoctorReport): string { - return JSON.stringify( - { - ok: report.ok, - basePath: report.basePath, - generatedAt: new Date().toISOString(), - summary: summarizeDoctorIssues(report.issues), - issues: report.issues, - fixesApplied: report.fixesApplied, - ...(report.timing ? { timing: report.timing } : {}), - }, - null, - 2, - ); -} diff --git a/src/resources/extensions/gsd/doctor-git-checks.ts b/src/resources/extensions/gsd/doctor-git-checks.ts deleted file mode 100644 index 6a6e4bea6..000000000 --- a/src/resources/extensions/gsd/doctor-git-checks.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { existsSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs"; -import { join, sep } from "node:path"; - -import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; -import { loadFile } from "./files.js"; -import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js"; -import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; -import { resolveMilestoneFile } from "./paths.js"; -import { deriveState, isMilestoneComplete } from "./state.js"; -import { listWorktrees, resolveGitDir, worktreesDir } from "./worktree-manager.js"; -import { abortAndReset } from "./git-self-heal.js"; -import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js"; -import { nativeIsRepo, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js"; -import { getAllWorktreeHealth } from "./worktree-health.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; - -/** - * Returns true if the directory contains only doctor artifacts - * (e.g. `.gsd/doctor-history.jsonl`). These dirs are created by - * appendDoctorHistory() writing to worktree-scoped paths during the audit - * and should not be flagged as orphaned worktrees (#3105). - */ -function isDoctorArtifactOnly(dirPath: string): boolean { - try { - const entries = readdirSync(dirPath); - // Empty dir — not a doctor artifact, still orphaned - if (entries.length === 0) return false; - // Only a .gsd subdirectory - if (entries.length === 1 && entries[0] === ".gsd") { - const gsdEntries = readdirSync(join(dirPath, ".gsd")); - return gsdEntries.length <= 1 && gsdEntries.every(e => e === "doctor-history.jsonl"); - } - return false; - } catch { - return false; - } -} - -export async function checkGitHealth( - basePath: string, - issues: DoctorIssue[], - fixesApplied: string[], - shouldFix: (code: DoctorIssueCode) => boolean, - isolationMode: "none" | "worktree" | "branch" = "none", -): Promise<void> { - // Degrade gracefully if not a git repo - if (!nativeIsRepo(basePath)) { - return; // Not a git repo — skip all git health checks - } - - const gitDir = resolveGitDir(basePath); - - // ── Orphaned auto-worktrees & Stale milestone branches ──────────────── - // These checks only apply in worktree/branch modes — skip in none mode - // where no milestone worktrees or branches are created. - if (isolationMode !== "none") { - try { - const worktrees = listWorktrees(basePath); - const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/")); - - // Load roadmap state once for cross-referencing - const state = await deriveState(basePath); - - for (const wt of milestoneWorktrees) { - // Extract milestone ID from branch name "milestone/M001" → "M001" - const milestoneId = wt.branch.replace(/^milestone\//, ""); - const milestoneEntry = state.registry.find(m => m.id === milestoneId); - - // Check if milestone is complete via roadmap - let isComplete = false; - if (milestoneEntry) { - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - isComplete = dbSlices.length > 0 && dbSlices.every(s => s.status === "complete"); - } else { - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (roadmapContent) { - const roadmap = parseLegacyRoadmap(roadmapContent); - isComplete = isMilestoneComplete(roadmap); - } - } - // When DB unavailable and no roadmap, isComplete stays false - } - - if (isComplete) { - issues.push({ - severity: "warning", - code: "orphaned_auto_worktree", - scope: "milestone", - unitId: milestoneId, - message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`, - fixable: true, - }); - - if (shouldFix("orphaned_auto_worktree")) { - // If cwd is inside the worktree, chdir out first — matching the - // pattern in removeWorktree() (#1946). Without this, git cannot - // remove the worktree and the doctor enters a deadlock where it - // detects the orphan every run but never cleans it up. - const cwd = process.cwd(); - if (wt.path === cwd || cwd.startsWith(wt.path + sep)) { - try { - process.chdir(basePath); - } catch { - fixesApplied.push(`skipped removing worktree at ${wt.path} (cannot chdir to basePath)`); - continue; - } - } - try { - nativeWorktreeRemove(basePath, wt.path, true); - fixesApplied.push(`removed orphaned worktree ${wt.path}`); - } catch { - fixesApplied.push(`failed to remove worktree ${wt.path}`); - } - } - } - } - - // ── Stale milestone branches ───────────────────────────────────────── - try { - const branches = nativeBranchList(basePath, "milestone/*"); - if (branches.length > 0) { - const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch)); - - for (const branch of branches) { - // Skip branches that have a worktree (handled above) - if (worktreeBranches.has(branch)) continue; - - const milestoneId = branch.replace(/^milestone\//, ""); - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - let branchMilestoneComplete = false; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - branchMilestoneComplete = dbSlices.length > 0 && dbSlices.every(s => s.status === "complete"); - } else { - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (!roadmapContent) continue; - const roadmap = parseLegacyRoadmap(roadmapContent); - branchMilestoneComplete = isMilestoneComplete(roadmap); - } - if (branchMilestoneComplete) { - issues.push({ - severity: "info", - code: "stale_milestone_branch", - scope: "milestone", - unitId: milestoneId, - message: `Branch ${branch} exists for completed milestone ${milestoneId}`, - fixable: true, - }); - - if (shouldFix("stale_milestone_branch")) { - try { - nativeBranchDelete(basePath, branch, true); - fixesApplied.push(`deleted stale branch ${branch}`); - } catch { - fixesApplied.push(`failed to delete branch ${branch}`); - } - } - } - } - } - } catch { - // git branch list failed — skip stale branch check - } - } catch { - // listWorktrees or deriveState failed — skip worktree/branch checks - } - } // end isolationMode !== "none" - - // ── Corrupt merge state ──────────────────────────────────────────────── - try { - const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"]; - const mergeStateDirs = ["rebase-apply", "rebase-merge"]; - const found: string[] = []; - - for (const f of mergeStateFiles) { - if (existsSync(join(gitDir, f))) found.push(f); - } - for (const d of mergeStateDirs) { - if (existsSync(join(gitDir, d))) found.push(d); - } - - if (found.length > 0) { - issues.push({ - severity: "error", - code: "corrupt_merge_state", - scope: "project", - unitId: "project", - message: `Corrupt merge/rebase state detected: ${found.join(", ")}`, - fixable: true, - }); - - if (shouldFix("corrupt_merge_state")) { - const result = abortAndReset(basePath); - fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`); - } - } - } catch { - // Can't check .git dir — skip - } - - // ── Tracked runtime files ────────────────────────────────────────────── - try { - const trackedPaths: string[] = []; - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - try { - const files = nativeLsFiles(basePath, exclusion); - if (files.length > 0) { - trackedPaths.push(...files); - } - } catch { - // Individual ls-files can fail — continue - } - } - - if (trackedPaths.length > 0) { - issues.push({ - severity: "warning", - code: "tracked_runtime_files", - scope: "project", - unitId: "project", - message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`, - fixable: true, - }); - - if (shouldFix("tracked_runtime_files")) { - try { - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - nativeRmCached(basePath, [exclusion]); - } - fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`); - } catch { - fixesApplied.push("failed to untrack runtime files"); - } - } - } - } catch { - // git ls-files failed — skip - } - - // ── Legacy slice branches ────────────────────────────────────────────── - try { - const branchList = nativeBranchList(basePath, "gsd/*/*") - .filter((branch) => !branch.startsWith("gsd/quick/")); - if (branchList.length > 0) { - issues.push({ - severity: "info", - code: "legacy_slice_branches", - scope: "project", - unitId: "project", - message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`, - fixable: true, - }); - - if (shouldFix("legacy_slice_branches")) { - let deleted = 0; - for (const branch of branchList) { - try { - nativeBranchDelete(basePath, branch, true); - deleted++; - } catch { /* skip branches that can't be deleted */ } - } - if (deleted > 0) { - fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`); - } - } - } - } catch { - // git branch list failed — skip - } - - // ── Integration branch existence ────────────────────────────────────── - // For each active (non-complete) milestone, verify the stored integration - // branch still exists in git. A missing integration branch blocks merge-back - // and causes the next merge operation to fail silently. - try { - const state = await deriveState(basePath); - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; - for (const milestone of state.registry) { - if (milestone.status === "complete") continue; - const resolution = resolveMilestoneIntegrationBranch(basePath, milestone.id, gitPrefs); - if (!resolution.recordedBranch) continue; // No stored branch — skip (not yet set) - if (resolution.status === "fallback" && resolution.effectiveBranch) { - issues.push({ - severity: "warning", - code: "integration_branch_missing", - scope: "milestone", - unitId: milestone.id, - message: resolution.reason, - fixable: true, - }); - if (shouldFix("integration_branch_missing")) { - writeIntegrationBranch(basePath, milestone.id, resolution.effectiveBranch); - fixesApplied.push(`updated integration branch for ${milestone.id} to "${resolution.effectiveBranch}"`); - } - continue; - } - - if (resolution.status === "missing") { - issues.push({ - severity: "error", - code: "integration_branch_missing", - scope: "milestone", - unitId: milestone.id, - message: resolution.reason, - fixable: false, - }); - } - } - } catch { - // Non-fatal — integration branch check failed - } - - // ── Orphaned worktree directories ──────────────────────────────────── - // Worktree removal can fail after a branch delete, leaving a directory - // that is no longer registered with git. These orphaned dirs cause - // "already exists" errors when re-creating the same worktree name. - try { - const wtDir = worktreesDir(basePath); - if (existsSync(wtDir)) { - // Resolve symlinks and normalize separators so that symlinked .gsd - // paths (e.g. ~/.gsd/projects/<hash>/worktrees/…) match the paths - // returned by `git worktree list`. - const normalizePath = (p: string): string => { - try { p = realpathSync(p); } catch { /* path may not exist */ } - return p.replaceAll("\\", "/"); - }; - const registeredPaths = new Set( - nativeWorktreeList(basePath).map(entry => normalizePath(entry.path)), - ); - for (const entry of readdirSync(wtDir)) { - const fullPath = join(wtDir, entry); - try { - if (!statSync(fullPath).isDirectory()) continue; - } catch { continue; } - const normalizedFullPath = normalizePath(fullPath); - if (!registeredPaths.has(normalizedFullPath)) { - // Skip directories that only contain doctor artifacts (.gsd/doctor-history.jsonl). - // appendDoctorHistory() can recreate these dirs during the audit itself, - // causing a circular false positive (#3105 Bug 1). - if (isDoctorArtifactOnly(fullPath)) continue; - issues.push({ - severity: "warning", - code: "worktree_directory_orphaned", - scope: "project", - unitId: entry, - message: `Worktree directory ${fullPath} exists on disk but is not registered with git. Run "git worktree prune" or doctor --fix to remove it.`, - fixable: true, - }); - if (shouldFix("worktree_directory_orphaned")) { - try { - rmSync(fullPath, { recursive: true, force: true }); - fixesApplied.push(`removed orphaned worktree directory ${fullPath}`); - } catch { - fixesApplied.push(`failed to remove orphaned worktree directory ${fullPath}`); - } - } - } - } - } - } catch { - // Non-fatal — orphaned worktree directory check failed - } - - // ── Stale uncommitted changes ──────────────────────────────────────────── - // If the working tree has uncommitted changes and the last commit was - // longer ago than the configured threshold, flag it and optionally - // auto-commit a safety snapshot so work isn't lost. - try { - const prefs = loadEffectiveGSDPreferences()?.preferences ?? {}; - const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; - - if (thresholdMinutes > 0) { - const dirty = nativeHasChanges(basePath); - if (dirty) { - const branch = nativeGetCurrentBranch(basePath); - const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); - const nowEpoch = Math.floor(Date.now() / 1000); - const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; - - if (minutesSinceCommit >= thresholdMinutes) { - const mins = Math.floor(minutesSinceCommit); - issues.push({ - severity: "warning", - code: "stale_uncommitted_changes", - scope: "project", - unitId: "project", - message: `Uncommitted changes detected with no commit in ${mins} minute${mins === 1 ? "" : "s"} (threshold: ${thresholdMinutes}m). Snapshotting tracked files.`, - fixable: true, - }); - - if (shouldFix("stale_uncommitted_changes")) { - try { - nativeAddTracked(basePath); - const commitMsg = `gsd snapshot: uncommitted changes after ${mins}m inactivity`; - const result = nativeCommit(basePath, commitMsg); - if (result) { - fixesApplied.push(`created gsd snapshot after ${mins}m of uncommitted changes`); - } else { - fixesApplied.push("gsd snapshot skipped — nothing to commit after staging tracked files"); - } - } catch { - fixesApplied.push("failed to create gsd snapshot commit"); - } - } - } - } - } - } catch { - // Non-fatal — stale commit check failed - } - - // ── Worktree lifecycle checks ────────────────────────────────────────── - // Check SF-managed worktrees for: merged branches, stale work, dirty - // state, and unpushed commits. Only worktrees under .gsd/worktrees/. - try { - const healthStatuses = getAllWorktreeHealth(basePath); - const cwd = process.cwd(); - - for (const health of healthStatuses) { - const wt = health.worktree; - const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep); - - // Branch fully merged into main — safe to remove - if (health.mergedIntoMain) { - issues.push({ - severity: "info", - code: "worktree_branch_merged", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`, - fixable: health.safeToRemove, - }); - - if (health.safeToRemove && shouldFix("worktree_branch_merged") && !isCwd) { - try { - const { removeWorktree } = await import("./worktree-manager.js"); - removeWorktree(basePath, wt.name, { deleteBranch: true, branch: wt.branch }); - fixesApplied.push(`removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`); - } catch { - fixesApplied.push(`failed to remove merged worktree "${wt.name}"`); - } - } - // If merged, skip the stale/dirty/unpushed checks — they're irrelevant - continue; - } - - // Stale: no commits in N days, not merged - if (health.stale) { - const days = Math.floor(health.lastCommitAgeDays); - issues.push({ - severity: "warning", - code: "worktree_stale", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`, - fixable: false, - }); - } - - // Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise) - if (health.dirty && health.stale) { - issues.push({ - severity: "warning", - code: "worktree_dirty", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`, - fixable: false, - }); - } - - // Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise) - if (health.unpushedCommits > 0 && health.stale) { - issues.push({ - severity: "warning", - code: "worktree_unpushed", - scope: "project", - unitId: wt.name, - message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`, - fixable: false, - }); - } - } - } catch { - // Non-fatal — worktree lifecycle check failed - } -} diff --git a/src/resources/extensions/gsd/doctor-global-checks.ts b/src/resources/extensions/gsd/doctor-global-checks.ts deleted file mode 100644 index d7d0cbd49..000000000 --- a/src/resources/extensions/gsd/doctor-global-checks.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { existsSync, readdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; - -import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; -import { readRepoMeta, externalProjectsRoot } from "./repo-identity.js"; - -/** - * Check for orphaned project state directories in ~/.gsd/projects/. - * - * A project directory is orphaned when its recorded gitRoot no longer exists - * on disk — the repo was deleted, moved, or the external drive was unmounted. - * These directories accumulate silently and waste disk space. - * - * Severity: info — orphaned state is harmless but takes disk space. - * Fixable: yes — rmSync the directory. Never auto-fixed at fixLevel="task". - */ -export async function checkGlobalHealth( - issues: DoctorIssue[], - fixesApplied: string[], - shouldFix: (code: DoctorIssueCode) => boolean, -): Promise<void> { - try { - const projectsDir = externalProjectsRoot(); - - if (!existsSync(projectsDir)) return; - - let entries: string[]; - try { - entries = readdirSync(projectsDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch { - return; // Can't read directory — skip - } - - if (entries.length === 0) return; - - const orphaned: Array<{ hash: string; gitRoot: string; remoteUrl: string }> = []; - let unknownCount = 0; - - for (const hash of entries) { - const dirPath = join(projectsDir, hash); - const meta = readRepoMeta(dirPath); - if (!meta) { - unknownCount++; - continue; - } - if (!existsSync(meta.gitRoot)) { - orphaned.push({ hash, gitRoot: meta.gitRoot, remoteUrl: meta.remoteUrl }); - } - } - - if (orphaned.length === 0) return; - - const labels = orphaned.slice(0, 3).map(o => o.gitRoot).join(", "); - const overflow = orphaned.length > 3 ? ` (+${orphaned.length - 3} more)` : ""; - const unknownNote = unknownCount > 0 ? ` — ${unknownCount} additional director${unknownCount === 1 ? "y" : "ies"} have no metadata yet (open those repos once to register them)` : ""; - - issues.push({ - severity: "info", - code: "orphaned_project_state", - scope: "project", - unitId: "global", - message: `${orphaned.length} orphaned SF project state director${orphaned.length === 1 ? "y" : "ies"} in ${projectsDir} whose git root no longer exists: ${labels}${overflow}${unknownNote}. Run /gsd cleanup projects to audit or /gsd cleanup projects --fix to reclaim disk space.`, - file: projectsDir, - fixable: true, - }); - - if (shouldFix("orphaned_project_state")) { - let removed = 0; - for (const { hash } of orphaned) { - try { - rmSync(join(projectsDir, hash), { recursive: true, force: true }); - removed++; - } catch { - // Individual removal failure is non-fatal — continue with remaining - } - } - fixesApplied.push(`removed ${removed} orphaned project state director${removed === 1 ? "y" : "ies"} from ${projectsDir}`); - } - } catch { - // Non-fatal — global health check must not block per-project doctor - } -} diff --git a/src/resources/extensions/gsd/doctor-proactive.ts b/src/resources/extensions/gsd/doctor-proactive.ts deleted file mode 100644 index e80723c17..000000000 --- a/src/resources/extensions/gsd/doctor-proactive.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * SF Doctor — Proactive Healing Layer - * - * Three mechanisms for automatic health monitoring during auto-mode: - * - * 1. Pre-dispatch health gate: lightweight check before each unit dispatch. - * Returns blocking issues that should pause auto-mode rather than - * dispatching into a broken state. - * - * 2. Health score tracking: tracks issue counts over time to detect - * degradation trends. If health is declining, surfaces a warning. - * - * 3. Auto-heal escalation: if deterministic fix can't resolve issues - * after N units, escalates to LLM-assisted heal dispatch. - */ - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot, resolveGsdRootFile } from "./paths.js"; -import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js"; -import { abortAndReset } from "./git-self-heal.js"; -import { rebuildState } from "./doctor.js"; -import { deriveState } from "./state.js"; -import { resolveMilestoneIntegrationBranch } from "./git-service.js"; -import { nativeIsRepo, nativeHasChanges, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeAddTracked, nativeCommit } from "./native-git-bridge.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { runEnvironmentChecks } from "./doctor-environment.js"; - -// ── Health Score Tracking ────────────────────────────────────────────────── - -/** Compact issue detail stored per snapshot for real-time visibility. */ -export interface HealthIssueDetail { - code: string; - message: string; - severity: "error" | "warning" | "info"; - unitId: string; -} - -export interface HealthSnapshot { - timestamp: number; - errors: number; - warnings: number; - fixesApplied: number; - unitIndex: number; // which unit dispatch triggered this snapshot - /** Top issues from the doctor run that produced this snapshot. */ - issues: HealthIssueDetail[]; - /** Fixes that were auto-applied during this snapshot's doctor run. */ - fixes: string[]; - /** Milestone/slice scope this snapshot belongs to (e.g. "M001" or "M001/S02"). */ - scope?: string; -} - -/** In-memory health history for the current auto-mode session. */ -let healthHistory: HealthSnapshot[] = []; - -/** Count of consecutive units with unresolved errors. */ -let consecutiveErrorUnits = 0; - -/** Unit index counter for health tracking. */ -let healthUnitIndex = 0; - -/** Previous progress level for state transition detection. */ -let previousProgressLevel: "green" | "yellow" | "red" = "green"; - -/** Callback for state transition notifications. Set by auto-mode. */ -let onLevelChange: ((from: string, to: string, summary: string) => void) | null = null; - -/** - * Register a callback for progress level transitions (green→yellow, yellow→red, etc.). - * Called once when auto-mode starts. Pass null to unregister. - */ -export function setLevelChangeCallback(cb: ((from: string, to: string, summary: string) => void) | null): void { - onLevelChange = cb; - previousProgressLevel = "green"; -} - -/** - * Record a health snapshot after a doctor run. - * Called from the post-unit hook in auto-post-unit.ts. - */ -export function recordHealthSnapshot( - errors: number, - warnings: number, - fixesApplied: number, - issues?: HealthIssueDetail[], - fixes?: string[], - scope?: string, -): void { - healthUnitIndex++; - healthHistory.push({ - timestamp: Date.now(), - errors, - warnings, - fixesApplied, - unitIndex: healthUnitIndex, - issues: issues ?? [], - fixes: fixes ?? [], - scope, - }); - - // Keep only the last 50 snapshots to bound memory - if (healthHistory.length > 50) { - healthHistory = healthHistory.slice(-50); - } - - if (errors > 0) { - consecutiveErrorUnits++; - } else { - consecutiveErrorUnits = 0; - } - - // Detect progress level transitions and notify - if (onLevelChange) { - const newLevel = consecutiveErrorUnits >= 3 ? "red" - : consecutiveErrorUnits >= 1 || getHealthTrend() === "degrading" ? "yellow" - : "green"; - if (newLevel !== previousProgressLevel) { - const topIssue = (issues ?? []).find(i => i.severity === "error") ?? (issues ?? [])[0]; - const detail = topIssue ? `: ${topIssue.message}` : ""; - onLevelChange(previousProgressLevel, newLevel, `Health ${previousProgressLevel} → ${newLevel}${detail}`); - previousProgressLevel = newLevel; - } - } -} - -/** - * Get the current health trend. - * Returns "improving", "stable", "degrading", or "unknown" (not enough data). - */ -export function getHealthTrend(): "improving" | "stable" | "degrading" | "unknown" { - if (healthHistory.length < 3) return "unknown"; - - const recent = healthHistory.slice(-5); - const older = healthHistory.slice(-10, -5); - - if (older.length === 0) return "unknown"; - - const recentAvg = recent.reduce((sum, s) => sum + s.errors + s.warnings, 0) / recent.length; - const olderAvg = older.reduce((sum, s) => sum + s.errors + s.warnings, 0) / older.length; - - const delta = recentAvg - olderAvg; - if (delta > 1) return "degrading"; - if (delta < -1) return "improving"; - return "stable"; -} - -/** - * Get the number of consecutive units with unresolved errors. - */ -export function getConsecutiveErrorUnits(): number { - return consecutiveErrorUnits; -} - -/** - * Get health history for display (e.g., dashboard overlay). - */ -export function getHealthHistory(): readonly HealthSnapshot[] { - return healthHistory; -} - -/** - * Get the latest health issues from the most recent snapshot. - * Returns issues from the last snapshot that had any, for real-time visibility. - */ -export function getLatestHealthIssues(): HealthIssueDetail[] { - for (let i = healthHistory.length - 1; i >= 0; i--) { - if (healthHistory[i]!.issues.length > 0) return healthHistory[i]!.issues; - } - return []; -} - -/** - * Get the latest fixes applied from the most recent snapshot. - */ -export function getLatestHealthFixes(): string[] { - for (let i = healthHistory.length - 1; i >= 0; i--) { - if (healthHistory[i]!.fixes.length > 0) return healthHistory[i]!.fixes; - } - return []; -} - -/** - * Reset health tracking state. Called on auto-mode start/stop. - */ -export function resetHealthTracking(): void { - healthHistory = []; - consecutiveErrorUnits = 0; - healthUnitIndex = 0; - previousProgressLevel = "green"; -} - -// ── Pre-Dispatch Health Gate ─────────────────────────────────────────────── - -export interface PreDispatchHealthResult { - /** Whether the dispatch should proceed. */ - proceed: boolean; - /** If blocked, the reason to show the user. */ - reason?: string; - /** Issues found (for logging). */ - issues: string[]; - /** Whether fix was applied. */ - fixesApplied: string[]; -} - -/** - * Lightweight pre-dispatch health check. Runs fast checks that should - * block dispatch if they fail — avoids dispatching into a broken state. - * - * This is NOT a full doctor run — it only checks critical, fast-to-evaluate - * conditions that would cause the next unit to fail or corrupt state. - * - * Returns { proceed: true } if dispatch should continue. - */ -export async function preDispatchHealthGate(basePath: string): Promise<PreDispatchHealthResult> { - const issues: string[] = []; - const fixesApplied: string[] = []; - - // ── Stale crash lock blocks dispatch ── - // If a stale lock exists, the crash recovery path should handle it, - // not a new dispatch. This prevents double-dispatch after crashes. - try { - const lock = readCrashLock(basePath); - if (lock && !isLockProcessAlive(lock)) { - // Auto-clear it since we're about to dispatch anyway - clearLock(basePath); - fixesApplied.push("cleared stale auto.lock before dispatch"); - } - } catch { - // Non-fatal - } - - // ── Corrupt merge/rebase state blocks dispatch ── - // Dispatching a unit with MERGE_HEAD present will cause git operations to fail. - try { - const gitDir = join(basePath, ".git"); - if (existsSync(gitDir)) { - const blockers = ["MERGE_HEAD", "rebase-apply", "rebase-merge"].filter( - f => existsSync(join(gitDir, f)), - ); - if (blockers.length > 0) { - // Try to auto-heal - try { - const result = abortAndReset(basePath); - fixesApplied.push(`pre-dispatch: cleaned merge state (${result.cleaned.join(", ")})`); - } catch { - issues.push(`Corrupt git state: ${blockers.join(", ")}. Run /gsd doctor fix.`); - } - } - } - } catch { - // Non-fatal - } - - // ── STATE.md existence check ── - // If STATE.md is missing, attempt to rebuild it for the next unit's context. - // Non-blocking — fresh worktrees won't have it until the first unit completes (#889). - try { - const stateFile = resolveGsdRootFile(basePath, "STATE"); - const milestonesDir = join(gsdRoot(basePath), "milestones"); - if (existsSync(milestonesDir) && !existsSync(stateFile)) { - try { - await rebuildState(basePath); - fixesApplied.push("rebuilt missing STATE.md before dispatch"); - } catch { - // Rebuild failed — non-blocking, dispatch continues - fixesApplied.push("STATE.md missing — will rebuild after first unit completes"); - } - } - } catch { - // Non-fatal — dispatch continues without STATE.md if rebuild fails - } - - // ── Integration branch existence check ── - // If the active milestone's recorded integration branch no longer exists in - // git, the merge-back at the end of the milestone will fail. Block dispatch - // now to surface this before work is lost. - try { - if (nativeIsRepo(basePath)) { - const state = await deriveState(basePath); - if (state.activeMilestone) { - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {}; - const resolution = resolveMilestoneIntegrationBranch(basePath, state.activeMilestone.id, gitPrefs); - if (resolution.status === "fallback" && resolution.effectiveBranch) { - fixesApplied.push( - `using fallback integration branch "${resolution.effectiveBranch}" for milestone ${state.activeMilestone.id}; recorded "${resolution.recordedBranch}" no longer exists`, - ); - } else if (resolution.recordedBranch && resolution.status === "missing") { - issues.push( - `${resolution.reason} Restore the branch or update the integration branch before dispatching. Run /gsd doctor for details.`, - ); - } - } - } - } catch { - // Non-fatal — dispatch continues if state/branch check fails - } - - // ── Stale uncommitted changes — auto-snapshot before dispatch ── - // If the working tree is dirty and no commit has happened recently, - // create a safety snapshot so work isn't lost if the next unit crashes. - try { - if (nativeIsRepo(basePath)) { - const prefs = loadEffectiveGSDPreferences()?.preferences ?? {}; - const thresholdMinutes = prefs.stale_commit_threshold_minutes ?? 30; - - if (thresholdMinutes > 0 && nativeHasChanges(basePath)) { - const branch = nativeGetCurrentBranch(basePath); - const lastEpoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); - const nowEpoch = Math.floor(Date.now() / 1000); - const minutesSinceCommit = lastEpoch > 0 ? (nowEpoch - lastEpoch) / 60 : Infinity; - - if (minutesSinceCommit >= thresholdMinutes) { - const mins = Math.floor(minutesSinceCommit); - try { - nativeAddTracked(basePath); - const commitMsg = `gsd snapshot: pre-dispatch, uncommitted changes after ${mins}m inactivity`; - const result = nativeCommit(basePath, commitMsg); - if (result) { - fixesApplied.push(`pre-dispatch: created gsd snapshot after ${mins}m of uncommitted changes`); - } - } catch { - // Non-blocking — snapshot failed but dispatch can continue - fixesApplied.push("pre-dispatch: gsd snapshot failed"); - } - } - } - } - } catch { - // Non-fatal - } - - // ── Disk space check ── - // Catches low-disk conditions before dispatch rather than letting the unit - // fail mid-execution with ENOSPC (which wastes a full LLM turn). - try { - const envResults = runEnvironmentChecks(basePath); - const diskError = envResults.find(r => r.name === "disk_space" && r.status === "error"); - if (diskError) { - issues.push(`${diskError.message}${diskError.detail ? ` — ${diskError.detail}` : ""}`); - } - } catch { - // Non-fatal — dispatch continues if env check fails - } - - // If we had critical issues that couldn't be auto-healed, block dispatch - if (issues.length > 0) { - return { - proceed: false, - reason: `Pre-dispatch health check failed:\n${issues.map(i => ` - ${i}`).join("\n")}\nRun /gsd doctor fix to resolve.`, - issues, - fixesApplied, - }; - } - - return { proceed: true, issues, fixesApplied }; -} - -// ── Auto-Heal Escalation ────────────────────────────────────────────────── - -/** Threshold: escalate to LLM heal after this many consecutive error units. */ -const ESCALATION_THRESHOLD = 5; - -/** Whether an escalation has already been triggered this session (prevent spam). */ -let escalationTriggered = false; - -/** - * Check whether auto-heal should escalate from deterministic fix to - * LLM-assisted heal. Called after each post-unit doctor run. - * - * Returns the structured issue text for LLM dispatch, or null if - * escalation is not needed. - */ -export function checkHealEscalation( - errors: number, - unresolvedIssues: Array<{ code: string; message: string; unitId: string }>, -): { shouldEscalate: boolean; reason: string; issues: typeof unresolvedIssues } { - if (escalationTriggered) { - return { shouldEscalate: false, reason: "already escalated this session", issues: [] }; - } - - if (consecutiveErrorUnits < ESCALATION_THRESHOLD) { - return { - shouldEscalate: false, - reason: `${consecutiveErrorUnits}/${ESCALATION_THRESHOLD} consecutive error units`, - issues: [], - }; - } - - if (errors === 0) { - return { shouldEscalate: false, reason: "no errors to escalate", issues: [] }; - } - - const trend = getHealthTrend(); - if (trend === "improving") { - return { shouldEscalate: false, reason: "health is improving — deferring escalation", issues: [] }; - } - - escalationTriggered = true; - return { - shouldEscalate: true, - reason: `${consecutiveErrorUnits} consecutive units with unresolved errors (trend: ${trend})`, - issues: unresolvedIssues, - }; -} - -/** - * Reset escalation state. Called on auto-mode start/stop. - */ -export function resetEscalation(): void { - escalationTriggered = false; -} - -/** - * Format a health summary for display in the auto-mode dashboard. - * Human-readable with full words, not abbreviations. - */ -export function formatHealthSummary(): string { - if (healthHistory.length === 0) return "No health data yet."; - - const latest = healthHistory[healthHistory.length - 1]!; - const trend = getHealthTrend(); - const trendLabel = trend === "improving" ? "improving" - : trend === "degrading" ? "degrading" - : trend === "stable" ? "stable" - : "unknown"; - const totalFixes = healthHistory.reduce((sum, s) => sum + s.fixesApplied, 0); - - const parts: string[] = []; - - // Error/warning summary - if (latest.errors === 0 && latest.warnings === 0) { - parts.push("No issues"); - } else { - const counts: string[] = []; - if (latest.errors > 0) counts.push(`${latest.errors} error${latest.errors > 1 ? "s" : ""}`); - if (latest.warnings > 0) counts.push(`${latest.warnings} warning${latest.warnings > 1 ? "s" : ""}`); - parts.push(counts.join(", ")); - } - - parts.push(`trend ${trendLabel}`); - - if (totalFixes > 0) { - parts.push(`${totalFixes} fix${totalFixes > 1 ? "es" : ""} applied`); - } - - if (consecutiveErrorUnits > 0) { - parts.push(`${consecutiveErrorUnits} of ${ESCALATION_THRESHOLD} consecutive errors before escalation`); - } - - // Include top issue from latest snapshot - if (latest.issues.length > 0) { - const topIssue = latest.issues.find(i => i.severity === "error") ?? latest.issues[0]!; - parts.push(`latest: ${topIssue.message}`); - } - - return parts.join(" · "); -} - -/** - * Reset all proactive healing state. Called on auto-mode start/stop. - */ -export function resetProactiveHealing(): void { - resetHealthTracking(); - resetEscalation(); -} diff --git a/src/resources/extensions/gsd/doctor-providers.ts b/src/resources/extensions/gsd/doctor-providers.ts deleted file mode 100644 index e483972aa..000000000 --- a/src/resources/extensions/gsd/doctor-providers.ts +++ /dev/null @@ -1,439 +0,0 @@ -/** - * SF Doctor — Provider & Integration Health Checks - * - * Fast, deterministic checks for external service configuration. - * Checks key presence in auth.json and environment variables — no HTTP calls, - * no network I/O, always sub-10ms. - * - * Covers: - * - LLM providers required by the effective model preferences (per phase) - * - Remote questions channel if configured (Slack/Discord/Telegram token) - * - Optional search/tool integrations (Brave, Tavily, Jina, Context7) - */ - -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { AuthStorage } from "@sf-run/pi-coding-agent"; -import { getEnvApiKey } from "@sf-run/pi-ai"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; -import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js"; - -// ── Types ────────────────────────────────────────────────────────────────────── - -export type ProviderCheckStatus = "ok" | "warning" | "error" | "unconfigured"; - -export interface ProviderCheckResult { - /** Provider id from PROVIDER_REGISTRY (e.g. "anthropic", "slack_bot") */ - name: string; - /** Human-readable label */ - label: string; - /** Functional grouping */ - category: ProviderCategory; - status: ProviderCheckStatus; - message: string; - /** Optional extra detail (e.g. which env var to set) */ - detail?: string; - /** True if this provider is actively required by preferences */ - required: boolean; -} - -// ── Model → Provider ID mapping ─────────────────────────────────────────────── - -/** - * Infer the auth provider ID from a model string. - * Handles plain model IDs ("claude-sonnet-4-6") and prefixed ones ("openrouter/deepseek"). - */ -function modelToProviderId(model: string): string | null { - if (!model) return null; - - // Explicit provider prefix (e.g. "openrouter/deepseek-r1") - if (model.includes("/")) { - const prefix = model.split("/")[0].toLowerCase(); - // Map known prefixes to registry IDs - const prefixMap: Record<string, string> = { - "anthropic-vertex": "anthropic-vertex", - openrouter: "openrouter", - groq: "groq", - mistral: "mistral", - google: "google", - "google-vertex": "google-vertex", - anthropic: "anthropic", - openai: "openai", - "github-copilot": "github-copilot", - }; - if (prefixMap[prefix]) return prefixMap[prefix]; - } - - const lower = model.toLowerCase(); - if (lower.startsWith("claude")) return "anthropic"; - if (lower.startsWith("gpt-") || lower.startsWith("o1") || lower.startsWith("o3")) return "openai"; - if (lower.startsWith("gemini")) return "google"; - if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq"; - if (lower.startsWith("grok")) return "xai"; - if (lower.startsWith("mistral") || lower.startsWith("codestral")) return "mistral"; - - return null; -} - -/** Collect all model strings from effective preferences across all phases. */ -function collectConfiguredModelProviders(): Set<string> { - const providers = new Set<string>(); - - try { - const loaded = loadEffectiveGSDPreferences(); - const models = loaded?.preferences?.models; - if (!models) { - // Default: Anthropic - providers.add("anthropic"); - return providers; - } - - const modelEntries = typeof models === "object" ? Object.values(models) : []; - for (const entry of modelEntries) { - if (typeof entry === "string") { - const pid = modelToProviderId(entry); - if (pid) providers.add(pid); - continue; - } - - if (typeof entry === "object" && entry !== null && "model" in entry) { - const configuredProvider = "provider" in entry ? (entry as { provider?: unknown }).provider : undefined; - if (typeof configuredProvider === "string" && configuredProvider.trim().length > 0) { - providers.add(configuredProvider); - continue; - } - - const modelId = String((entry as { model: unknown }).model); - const pid = modelToProviderId(modelId); - if (pid) providers.add(pid); - } - } - } catch { - // Preferences not readable — assume Anthropic as default - providers.add("anthropic"); - } - - if (providers.size === 0) providers.add("anthropic"); - return providers; -} - -// ── Key resolution ───────────────────────────────────────────────────────────── - -interface KeyLookup { - found: boolean; - source: "auth.json" | "env" | "none"; - backedOff: boolean; -} - -function resolveKey(providerId: string): KeyLookup { - const info = PROVIDER_REGISTRY.find(p => p.id === providerId); - - if (providerId === "anthropic-vertex" && process.env.ANTHROPIC_VERTEX_PROJECT_ID) { - return { found: true, source: "env", backedOff: false }; - } - - // Check auth.json - const authPath = getAuthPath(); - if (existsSync(authPath)) { - try { - const auth = AuthStorage.create(authPath); - const creds = auth.getCredentialsForProvider(providerId); - if (creds.length > 0) { - // Filter out empty placeholder keys (from skipped onboarding) - const hasRealKey = creds.some(c => - c.type === "oauth" || (c.type === "api_key" && (c as { key?: string }).key) - ); - if (hasRealKey) { - return { - found: true, - source: "auth.json", - backedOff: auth.areAllCredentialsBackedOff(providerId), - }; - } - } - } catch { - // auth.json malformed — fall through to env check - } - } - - // Check environment variable using the authoritative env var resolution - // (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY, - // COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.) - if (getEnvApiKey(providerId)) { - return { found: true, source: "env", backedOff: false }; - } - - // Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey - // (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7) - if (info?.envVar && process.env[info.envVar]) { - return { found: true, source: "env", backedOff: false }; - } - - return { found: false, source: "none", backedOff: false }; -} - -// ── Individual check groups ──────────────────────────────────────────────────── - -/** - * Providers that can serve models normally associated with another provider. - * Key = the provider whose models can be served, Value = alternative providers to check. - * e.g. GitHub Copilot subscriptions can access Claude and GPT models. - */ -const PROVIDER_ROUTES: Record<string, string[]> = { - anthropic: ["github-copilot"], - openai: ["github-copilot", "openai-codex"], - google: ["google-gemini-cli"], -}; - -/** - * Providers that use external CLI authentication (not API keys). - * These are always considered "ok" — the host CLI handles auth. - */ -const CLI_AUTH_PROVIDERS = new Set([ - "claude-code", - "openai-codex", - "google-gemini-cli", - "google-antigravity", -]); - -function checkLlmProviders(): ProviderCheckResult[] { - const required = collectConfiguredModelProviders(); - const results: ProviderCheckResult[] = []; - - for (const providerId of required) { - // CLI-authenticated providers don't need API keys — skip key check - if (CLI_AUTH_PROVIDERS.has(providerId)) { - const info = PROVIDER_REGISTRY.find(p => p.id === providerId); - results.push({ - name: providerId, - label: info?.label ?? providerId, - category: "llm", - status: "ok", - message: `${info?.label ?? providerId} — CLI auth (no key needed)`, - required: true, - }); - continue; - } - const info = PROVIDER_REGISTRY.find(p => p.id === providerId); - const label = providerId === "anthropic-vertex" - ? "Anthropic Vertex" - : info?.label ?? providerId; - const lookup = resolveKey(providerId); - - if (!lookup.found) { - // Check if a cross-provider can serve this provider's models - const routes = PROVIDER_ROUTES[providerId]; - const routeProvider = routes?.find(routeId => resolveKey(routeId).found); - if (routeProvider) { - const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider); - const routeLabel = routeInfo?.label ?? routeProvider; - results.push({ - name: providerId, - label, - category: "llm", - status: "ok", - message: `${label} — available via ${routeLabel}`, - required: true, - }); - continue; - } - - const envVar = providerId === "anthropic-vertex" - ? "ANTHROPIC_VERTEX_PROJECT_ID" - : info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`; - results.push({ - name: providerId, - label, - category: "llm", - status: "error", - message: `${label} — not configured`, - detail: providerId === "anthropic-vertex" - ? "Set ANTHROPIC_VERTEX_PROJECT_ID and authenticate with Google ADC" - : info?.hasOAuth - ? `Run /gsd keys to authenticate` - : `Set ${envVar} or run /gsd keys`, - required: true, - }); - } else if (lookup.backedOff) { - results.push({ - name: providerId, - label, - category: "llm", - status: "warning", - message: `${label} — all credentials backed off (rate limited)`, - detail: `SF will retry automatically`, - required: true, - }); - } else { - results.push({ - name: providerId, - label, - category: "llm", - status: "ok", - message: `${label} — key present (${lookup.source})`, - required: true, - }); - } - } - - return results; -} - -function checkRemoteQuestionsProvider(): ProviderCheckResult | null { - try { - const loaded = loadEffectiveGSDPreferences(); - const rq = loaded?.preferences?.remote_questions; - if (!rq) return null; - - const channel = rq.channel as string | undefined; - if (!channel) return null; - - const providerMap: Record<string, string> = { - slack: "slack_bot", - discord: "discord_bot", - telegram: "telegram_bot", - }; - - const providerId = providerMap[channel.toLowerCase()]; - if (!providerId) return null; - - const info = PROVIDER_REGISTRY.find(p => p.id === providerId); - const label = info?.label ?? channel; - const lookup = resolveKey(providerId); - - if (!lookup.found) { - return { - name: providerId, - label, - category: "remote", - status: "warning", - message: `${label} — channel configured but token not found`, - detail: info?.envVar ? `Set ${info.envVar} or run /gsd keys` : `Run /gsd keys to configure`, - required: true, - }; - } - - return { - name: providerId, - label, - category: "remote", - status: "ok", - message: `${label} — token present (${lookup.source})`, - required: true, - }; - } catch { - return null; - } -} - -function checkOptionalProviders(): ProviderCheckResult[] { - const optional = ["brave", "tavily", "jina", "context7"] as const; - const results: ProviderCheckResult[] = []; - - // Determine which search providers are configured so we can suppress - // "not configured" noise for alternative search providers when at least - // one is already active (e.g. don't warn about missing BRAVE_API_KEY - // when Tavily is configured). - const searchProviderIds = ["brave", "tavily"] as const; - const hasAnySearchProvider = searchProviderIds.some(id => resolveKey(id).found); - - for (const providerId of optional) { - const info = PROVIDER_REGISTRY.find(p => p.id === providerId); - if (!info) continue; - - const lookup = resolveKey(providerId); - - // Skip unconfigured search providers when another search provider is active - if (!lookup.found && hasAnySearchProvider && info.category === "search") { - continue; - } - - results.push({ - name: providerId, - label: info.label, - category: info.category as ProviderCategory, - status: lookup.found ? "ok" : "unconfigured", - message: lookup.found - ? `${info.label} — key present (${lookup.source})` - : `${info.label} — not configured (optional)`, - detail: !lookup.found && info.envVar ? `Set ${info.envVar} to enable` : undefined, - required: false, - }); - } - - return results; -} - -// ── Public API ───────────────────────────────────────────────────────────────── - -/** - * Run all provider checks: required LLM keys, remote questions channel, optional tools. - * Fast (sub-10ms) — reads auth.json and env vars only, no network I/O. - */ -export function runProviderChecks(): ProviderCheckResult[] { - const results: ProviderCheckResult[] = []; - - results.push(...checkLlmProviders()); - - const remoteCheck = checkRemoteQuestionsProvider(); - if (remoteCheck) results.push(remoteCheck); - - results.push(...checkOptionalProviders()); - - return results; -} - -/** - * Format provider check results as a human-readable report string. - */ -export function formatProviderReport(results: ProviderCheckResult[]): string { - if (results.length === 0) return "No provider checks run."; - - const lines: string[] = []; - - const groups: Record<string, ProviderCheckResult[]> = {}; - for (const r of results) { - (groups[r.category] ??= []).push(r); - } - - const categoryLabels: Record<string, string> = { - llm: "LLM Providers", - remote: "Notifications", - search: "Search", - tool: "Tools", - }; - - for (const [cat, items] of Object.entries(groups)) { - lines.push(`${categoryLabels[cat] ?? cat}:`); - for (const item of items) { - const icon = item.status === "ok" ? "✓" - : item.status === "warning" ? "⚠" - : item.status === "error" ? "✗" - : "·"; - lines.push(` ${icon} ${item.message}`); - if (item.detail && item.status !== "ok") { - lines.push(` ${item.detail}`); - } - } - } - - return lines.join("\n"); -} - -/** - * Summarise check results to a compact widget-friendly string. - * Returns null if all required providers are ok. - */ -export function summariseProviderIssues(results: ProviderCheckResult[]): string | null { - const errors = results.filter(r => r.required && r.status === "error"); - const warnings = results.filter(r => r.required && r.status === "warning"); - - if (errors.length === 0 && warnings.length === 0) return null; - - const parts: string[] = []; - if (errors.length > 0) parts.push(`✗ ${errors[0].label} key missing`); - if (warnings.length > 0 && errors.length === 0) parts.push(`⚠ ${warnings[0].label} backed off`); - if (errors.length + warnings.length > 1) parts.push(`(+${errors.length + warnings.length - 1} more)`); - - return parts.join(" "); -} diff --git a/src/resources/extensions/gsd/doctor-runtime-checks.ts b/src/resources/extensions/gsd/doctor-runtime-checks.ts deleted file mode 100644 index 2ce16bf4c..000000000 --- a/src/resources/extensions/gsd/doctor-runtime-checks.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; - -import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; -import { cleanNumberedGsdVariants } from "./repo-identity.js"; -import { milestonesDir, gsdRoot, resolveGsdRootFile } from "./paths.js"; -import { deriveState } from "./state.js"; -import { saveFile } from "./files.js"; -import { nativeIsRepo, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; -import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js"; -import { ensureGitignore } from "./gitignore.js"; -import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js"; -import { recoverFailedMigration } from "./migrate-external.js"; - -export async function checkRuntimeHealth( - basePath: string, - issues: DoctorIssue[], - fixesApplied: string[], - shouldFix: (code: DoctorIssueCode) => boolean, -): Promise<void> { - const root = gsdRoot(basePath); - - // ── Stale crash lock ────────────────────────────────────────────────── - try { - const lock = readCrashLock(basePath); - if (lock) { - const alive = isLockProcessAlive(lock); - if (!alive) { - issues.push({ - severity: "error", - code: "stale_crash_lock", - scope: "project", - unitId: "project", - message: `Stale auto.lock from PID ${lock.pid} (started ${lock.startedAt}, was executing ${lock.unitType} ${lock.unitId}) — process is no longer running`, - file: ".gsd/auto.lock", - fixable: true, - }); - - if (shouldFix("stale_crash_lock")) { - clearLock(basePath); - fixesApplied.push("cleared stale auto.lock"); - } - } - } - } catch { - // Non-fatal — crash lock check failed - } - - // ── Stranded lock directory ──────────────────────────────────────────── - // proper-lockfile creates a `.gsd.lock/` directory as the OS-level lock - // mechanism. If the process was SIGKILLed or crashed hard, this directory - // can remain on disk without any live process holding it. The next session - // fails to acquire the lock until the directory is removed (#1245). - try { - const lockDir = join(dirname(root), `${basename(root)}.lock`); - if (existsSync(lockDir)) { - const statRes = statSync(lockDir); - if (statRes.isDirectory()) { - // Check if any live process actually holds this lock - const lock = readCrashLock(basePath); - const lockHolderAlive = lock ? isLockProcessAlive(lock) : false; - if (!lockHolderAlive) { - issues.push({ - severity: "error", - code: "stranded_lock_directory", - scope: "project", - unitId: "project", - message: `Stranded lock directory "${lockDir}" exists but no live process holds the session lock. This blocks new auto-mode sessions from starting.`, - file: lockDir, - fixable: true, - }); - if (shouldFix("stranded_lock_directory")) { - try { - rmSync(lockDir, { recursive: true, force: true }); - fixesApplied.push(`removed stranded lock directory ${lockDir}`); - } catch { - fixesApplied.push(`failed to remove stranded lock directory ${lockDir}`); - } - } - } - } - } - } catch { - // Non-fatal — stranded lock directory check failed - } - - // ── Stale parallel sessions ──────────────────────────────────────────── - try { - const parallelStatuses = readAllSessionStatuses(basePath); - for (const status of parallelStatuses) { - if (isSessionStale(status)) { - issues.push({ - severity: "warning", - code: "stale_parallel_session", - scope: "project", - unitId: status.milestoneId, - message: `Stale parallel session for ${status.milestoneId} (PID ${status.pid}, started ${new Date(status.startedAt).toISOString()}, last heartbeat ${new Date(status.lastHeartbeat).toISOString()}) — process is no longer running`, - file: `.gsd/parallel/${status.milestoneId}.status.json`, - fixable: true, - }); - - if (shouldFix("stale_parallel_session")) { - removeSessionStatus(basePath, status.milestoneId); - fixesApplied.push(`cleaned up stale parallel session for ${status.milestoneId}`); - } - } - } - } catch { - // Non-fatal — parallel session check failed - } - - // ── Orphaned completed-units keys ───────────────────────────────────── - try { - const completedKeysFile = join(root, "completed-units.json"); - if (existsSync(completedKeysFile)) { - const raw = readFileSync(completedKeysFile, "utf-8"); - const keys: string[] = JSON.parse(raw); - const orphaned: string[] = []; - - for (const key of keys) { - // Key format: "unitType/unitId" e.g. "execute-task/M001/S01/T01" - // Hook units have compound types: "hook/<hookName>/unitId" - const { splitCompletedKey } = await import("./forensics.js"); - const parsed = splitCompletedKey(key); - if (!parsed) continue; - const { unitType, unitId } = parsed; - - // Only validate artifact-producing unit types - const { verifyExpectedArtifact } = await import("./auto-recovery.js"); - if (!verifyExpectedArtifact(unitType, unitId, basePath)) { - orphaned.push(key); - } - } - - if (orphaned.length > 0) { - issues.push({ - severity: "warning", - code: "orphaned_completed_units", - scope: "project", - unitId: "project", - message: `${orphaned.length} completed-unit key(s) reference missing artifacts: ${orphaned.slice(0, 3).join(", ")}${orphaned.length > 3 ? "..." : ""}`, - file: ".gsd/completed-units.json", - fixable: true, - }); - - if (shouldFix("orphaned_completed_units")) { - const orphanedSet = new Set(orphaned); - const remaining = keys.filter((key) => !orphanedSet.has(key)); - await saveFile(completedKeysFile, JSON.stringify(remaining)); - fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`); - } - } - } - } catch { - // Non-fatal — completed-units check failed - } - - // ── Stale hook state ────────────────────────────────────────────────── - try { - const hookStateFile = join(root, "hook-state.json"); - if (existsSync(hookStateFile)) { - const raw = readFileSync(hookStateFile, "utf-8"); - const state = JSON.parse(raw); - const hasCycleCounts = state.cycleCounts && typeof state.cycleCounts === "object" - && Object.keys(state.cycleCounts).length > 0; - - // Only flag if there are actual cycle counts AND no auto-mode is running - if (hasCycleCounts) { - const lock = readCrashLock(basePath); - const autoRunning = lock ? isLockProcessAlive(lock) : false; - - if (!autoRunning) { - issues.push({ - severity: "info", - code: "stale_hook_state", - scope: "project", - unitId: "project", - message: `hook-state.json has ${Object.keys(state.cycleCounts).length} residual cycle count(s) from a previous session`, - file: ".gsd/hook-state.json", - fixable: true, - }); - - if (shouldFix("stale_hook_state")) { - const { clearPersistedHookState } = await import("./post-unit-hooks.js"); - clearPersistedHookState(basePath); - fixesApplied.push("cleared stale hook-state.json"); - } - } - } - } - } catch { - // Non-fatal — hook state check failed - } - - // ── Activity log bloat ──────────────────────────────────────────────── - try { - const activityDir = join(root, "activity"); - if (existsSync(activityDir)) { - const files = readdirSync(activityDir); - let totalSize = 0; - for (const f of files) { - try { - totalSize += statSync(join(activityDir, f)).size; - } catch { - // stat failed — skip - } - } - - const totalMB = totalSize / (1024 * 1024); - const BLOAT_FILE_THRESHOLD = 500; - const BLOAT_SIZE_MB = 100; - - if (files.length > BLOAT_FILE_THRESHOLD || totalMB > BLOAT_SIZE_MB) { - issues.push({ - severity: "warning", - code: "activity_log_bloat", - scope: "project", - unitId: "project", - message: `Activity logs: ${files.length} files, ${totalMB.toFixed(1)}MB (thresholds: ${BLOAT_FILE_THRESHOLD} files / ${BLOAT_SIZE_MB}MB)`, - file: ".gsd/activity/", - fixable: true, - }); - - if (shouldFix("activity_log_bloat")) { - const { pruneActivityLogs } = await import("./activity-log.js"); - pruneActivityLogs(activityDir, 7); // 7-day retention - fixesApplied.push("pruned activity logs (7-day retention)"); - } - } - } - } catch { - // Non-fatal — activity log check failed - } - - // ── STATE.md health ─────────────────────────────────────────────────── - try { - const stateFilePath = resolveGsdRootFile(basePath, "STATE"); - const milestonesPath = milestonesDir(basePath); - - if (existsSync(milestonesPath)) { - if (!existsSync(stateFilePath)) { - issues.push({ - severity: "warning", - code: "state_file_missing", - scope: "project", - unitId: "project", - message: "STATE.md is missing — state display will not work", - file: ".gsd/STATE.md", - fixable: true, - }); - - if (shouldFix("state_file_missing")) { - const state = await deriveState(basePath); - await saveFile(stateFilePath, buildStateMarkdownForCheck(state)); - fixesApplied.push("created STATE.md from derived state"); - } - } else { - // Check if STATE.md is stale by comparing active milestone/slice/phase - const currentContent = readFileSync(stateFilePath, "utf-8"); - const state = await deriveState(basePath); - const freshContent = buildStateMarkdownForCheck(state); - - // Extract key fields for comparison — don't compare full content - // since timestamp/formatting differences are normal - const extractFields = (content: string) => { - const milestone = content.match(/\*\*Active Milestone:\*\*\s*(.+)/)?.[1]?.trim() ?? ""; - const slice = content.match(/\*\*Active Slice:\*\*\s*(.+)/)?.[1]?.trim() ?? ""; - const phase = content.match(/\*\*Phase:\*\*\s*(.+)/)?.[1]?.trim() ?? ""; - return { milestone, slice, phase }; - }; - - const current = extractFields(currentContent); - const fresh = extractFields(freshContent); - - if (current.milestone !== fresh.milestone || current.slice !== fresh.slice || current.phase !== fresh.phase) { - issues.push({ - severity: "warning", - code: "state_file_stale", - scope: "project", - unitId: "project", - message: `STATE.md is stale — shows "${current.phase}" but derived state is "${fresh.phase}"`, - file: ".gsd/STATE.md", - fixable: true, - }); - - if (shouldFix("state_file_stale")) { - await saveFile(stateFilePath, freshContent); - fixesApplied.push("rebuilt STATE.md from derived state"); - } - } - } - } - } catch { - // Non-fatal — STATE.md check failed - } - - // ── Gitignore drift ─────────────────────────────────────────────────── - try { - const gitignorePath = join(basePath, ".gitignore"); - if (existsSync(gitignorePath) && nativeIsRepo(basePath)) { - const content = readFileSync(gitignorePath, "utf-8"); - const existingLines = new Set( - content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")), - ); - - // Check for critical runtime patterns that must be present. - // NOTE: SF_RUNTIME_PATTERNS in gitignore.ts is the canonical source of truth. - // This is a minimal subset for the doctor check. - const criticalPatterns = [ - ".gsd/activity/", - ".gsd/runtime/", - ".gsd/auto.lock", - ".gsd/gsd.db*", - ".gsd/completed-units*.json", - ".gsd/event-log.jsonl", - ]; - - // If blanket .gsd/ or .gsd is present, all patterns are covered - const hasBlanketIgnore = existingLines.has(".gsd/") || existingLines.has(".gsd"); - - if (!hasBlanketIgnore) { - const missing = criticalPatterns.filter(p => !existingLines.has(p)); - if (missing.length > 0) { - issues.push({ - severity: "warning", - code: "gitignore_missing_patterns", - scope: "project", - unitId: "project", - message: `${missing.length} critical SF runtime pattern(s) missing from .gitignore: ${missing.join(", ")}`, - file: ".gitignore", - fixable: true, - }); - - if (shouldFix("gitignore_missing_patterns")) { - ensureGitignore(basePath); - fixesApplied.push("added missing SF runtime patterns to .gitignore"); - } - } - } - } - } catch { - // Non-fatal — gitignore check failed - } - - // ── External state symlink health ────────────────────────────────────── - try { - const localGsd = join(basePath, ".gsd"); - if (existsSync(localGsd)) { - const stat = lstatSync(localGsd); - - // Check for .gsd.migrating (failed migration) - const migratingPath = join(basePath, ".gsd.migrating"); - if (existsSync(migratingPath)) { - issues.push({ - severity: "error", - code: "failed_migration", - scope: "project", - unitId: "project", - message: "Found .gsd.migrating — a previous external state migration failed. State may be incomplete.", - file: ".gsd.migrating", - fixable: true, - }); - - if (shouldFix("failed_migration")) { - if (recoverFailedMigration(basePath)) { - fixesApplied.push("recovered failed migration (.gsd.migrating → .gsd)"); - } - } - } - - // Check symlink target exists - if (stat.isSymbolicLink()) { - try { - realpathSync(localGsd); - } catch { - issues.push({ - severity: "error", - code: "broken_symlink", - scope: "project", - unitId: "project", - message: ".gsd symlink target does not exist. External state directory may have been deleted.", - file: ".gsd", - fixable: false, - }); - } - } - } - } catch { - // Non-fatal — external state check failed - } - - // ── Numbered .gsd collision variants (#2205) ─────────────────────────── - // macOS APFS can create ".gsd 2", ".gsd 3" etc. when a directory blocks - // symlink creation. These must be removed so the canonical .gsd is used. - try { - const variantPattern = /^\.gsd \d+$/; - const entries = readdirSync(basePath); - const variants = entries.filter(e => variantPattern.test(e)); - if (variants.length > 0) { - for (const v of variants) { - issues.push({ - severity: "warning", - code: "numbered_gsd_variant", - scope: "project", - unitId: "project", - message: `Found macOS collision variant "${v}" — this can cause SF state to appear deleted.`, - file: v, - fixable: true, - }); - } - - if (shouldFix("numbered_gsd_variant")) { - const removed = cleanNumberedGsdVariants(basePath); - for (const name of removed) { - fixesApplied.push(`removed numbered .gsd variant: ${name}`); - } - } - } - } catch { - // Non-fatal — variant check failed - } - - // ── Metrics ledger integrity ─────────────────────────────────────────── - try { - const metricsPath = join(root, "metrics.json"); - if (existsSync(metricsPath)) { - try { - const raw = readFileSync(metricsPath, "utf-8"); - const ledger = JSON.parse(raw); - if (ledger.version !== 1 || !Array.isArray(ledger.units)) { - issues.push({ - severity: "warning", - code: "metrics_ledger_corrupt", - scope: "project", - unitId: "project", - message: "metrics.json has an unexpected structure (version !== 1 or units is not an array) — metrics data may be unreliable", - file: ".gsd/metrics.json", - fixable: false, - }); - } - } catch { - issues.push({ - severity: "warning", - code: "metrics_ledger_corrupt", - scope: "project", - unitId: "project", - message: "metrics.json is not valid JSON — metrics data may be corrupt", - file: ".gsd/metrics.json", - fixable: false, - }); - } - } - } catch { - // Non-fatal — metrics check failed - } - - // ── Metrics ledger bloat ────────────────────────────────────────────── - // The metrics ledger has no TTL and grows by one entry per completed unit. - // At 50 units/day a project can accumulate tens of thousands of entries over - // months of use. Prune to the newest 1500 when the threshold is exceeded. - try { - const metricsFilePath = join(root, "metrics.json"); - if (existsSync(metricsFilePath)) { - try { - const raw = readFileSync(metricsFilePath, "utf-8"); - const parsed = JSON.parse(raw); - const BLOAT_UNITS_THRESHOLD = 2000; - if (parsed.version === 1 && Array.isArray(parsed.units) && parsed.units.length > BLOAT_UNITS_THRESHOLD) { - const fileSizeMB = (statSync(metricsFilePath).size / (1024 * 1024)).toFixed(1); - issues.push({ - severity: "warning", - code: "metrics_ledger_bloat", - scope: "project", - unitId: "project", - message: `metrics.json has ${parsed.units.length} unit entries (${fileSizeMB}MB) — threshold is ${BLOAT_UNITS_THRESHOLD}. Run /gsd doctor --fix to prune to the newest 1500 entries.`, - file: ".gsd/metrics.json", - fixable: true, - }); - if (shouldFix("metrics_ledger_bloat")) { - const { pruneMetricsLedger } = await import("./metrics.js"); - const removed = pruneMetricsLedger(basePath, 1500); - fixesApplied.push(`pruned metrics ledger: removed ${removed} oldest entries (${parsed.units.length - removed} remain)`); - } - } - } catch { - // JSON parse failed — already handled by the integrity check above - } - } - } catch { - // Non-fatal — metrics bloat check failed - } - - // ── Large planning file detection ────────────────────────────────────── - // Files over 100KB can cause LLM context pressure. Report the worst offenders. - try { - const MAX_FILE_BYTES = 100 * 1024; // 100KB - const milestonesPath = milestonesDir(basePath); - if (existsSync(milestonesPath)) { - const largeFiles: Array<{ path: string; sizeKB: number }> = []; - function scanForLargeFiles(dir: string, depth = 0): void { - if (depth > 6) return; - try { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - try { - const s = statSync(full); - if (s.isDirectory()) { scanForLargeFiles(full, depth + 1); continue; } - if (entry.endsWith(".md") && s.size > MAX_FILE_BYTES) { - largeFiles.push({ path: full.replace(basePath + "/", ""), sizeKB: Math.round(s.size / 1024) }); - } - } catch { /* skip entry */ } - } - } catch { /* skip dir */ } - } - scanForLargeFiles(milestonesPath); - if (largeFiles.length > 0) { - largeFiles.sort((a, b) => b.sizeKB - a.sizeKB); - const worst = largeFiles[0]!; - issues.push({ - severity: "warning", - code: "large_planning_file", - scope: "project", - unitId: "project", - message: `${largeFiles.length} planning file(s) exceed 100KB — largest: ${worst.path} (${worst.sizeKB}KB). Large files cause LLM context pressure.`, - file: worst.path, - fixable: false, - }); - } - } - } catch { - // Non-fatal — large file scan failed - } - - // ── Snapshot ref bloat ──────────────────────────────────────────────── - // refs/gsd/snapshots/ accumulate over time. Prune to newest 5 per label - // when total count exceeds threshold. - try { - if (nativeIsRepo(basePath)) { - const refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); - if (refs.length > 50) { - issues.push({ - severity: "warning", - code: "snapshot_ref_bloat", - scope: "project", - unitId: "project", - message: `${refs.length} snapshot refs found under refs/gsd/snapshots/ — pruning to newest 5 per label will reclaim git storage`, - fixable: true, - }); - - if (shouldFix("snapshot_ref_bloat")) { - const byLabel = new Map<string, string[]>(); - for (const ref of refs) { - const parts = ref.split("/"); - const label = parts.slice(0, -1).join("/"); - if (!byLabel.has(label)) byLabel.set(label, []); - byLabel.get(label)!.push(ref); - } - let pruned = 0; - for (const [, labelRefs] of byLabel) { - const sorted = labelRefs.sort(); - for (const old of sorted.slice(0, -5)) { - try { - nativeUpdateRef(basePath, old); - pruned++; - } catch { /* skip */ } - } - } - if (pruned > 0) { - fixesApplied.push(`pruned ${pruned} old snapshot ref(s)`); - } - } - } - } - } catch { - // Non-fatal — snapshot ref check failed - } -} - -/** - * Build STATE.md markdown content from derived state. - * Local helper used by checkRuntimeHealth for STATE.md drift detection and repair. - */ -function buildStateMarkdownForCheck(state: Awaited<ReturnType<typeof deriveState>>): string { - const lines: string[] = []; - lines.push("# SF State", ""); - - const activeMilestone = state.activeMilestone - ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` - : "None"; - const activeSlice = state.activeSlice - ? `${state.activeSlice.id}: ${state.activeSlice.title}` - : "None"; - - lines.push(`**Active Milestone:** ${activeMilestone}`); - lines.push(`**Active Slice:** ${activeSlice}`); - lines.push(`**Phase:** ${state.phase}`); - if (state.requirements) { - lines.push(`**Requirements Status:** ${state.requirements.active} active · ${state.requirements.validated} validated · ${state.requirements.deferred} deferred · ${state.requirements.outOfScope} out of scope`); - } - lines.push(""); - lines.push("## Milestone Registry"); - - for (const entry of state.registry) { - const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : entry.status === "parked" ? "\u23F8\uFE0F" : "\u2B1C"; - lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); - } - - lines.push(""); - lines.push("## Recent Decisions"); - if (state.recentDecisions.length > 0) { - for (const decision of state.recentDecisions) lines.push(`- ${decision}`); - } else { - lines.push("- None recorded"); - } - - lines.push(""); - lines.push("## Blockers"); - if (state.blockers.length > 0) { - for (const blocker of state.blockers) lines.push(`- ${blocker}`); - } else { - lines.push("- None"); - } - - lines.push(""); - lines.push("## Next Action"); - lines.push(state.nextAction || "None"); - lines.push(""); - - return lines.join("\n"); -} diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts deleted file mode 100644 index b4cd539c1..000000000 --- a/src/resources/extensions/gsd/doctor-types.ts +++ /dev/null @@ -1,126 +0,0 @@ -export type DoctorSeverity = "info" | "warning" | "error"; -export type DoctorIssueCode = - | "invalid_preferences" - | "missing_tasks_dir" - | "missing_slice_plan" - | "all_slices_done_missing_milestone_validation" - | "all_slices_done_missing_milestone_summary" - | "task_done_must_haves_not_verified" - | "active_requirement_missing_owner" - | "blocked_requirement_missing_reason" - | "blocker_discovered_no_replan" - | "delimiter_in_title" - | "orphaned_auto_worktree" - | "stale_milestone_branch" - | "corrupt_merge_state" - | "tracked_runtime_files" - | "legacy_slice_branches" - | "stale_crash_lock" - | "stale_parallel_session" - | "orphaned_completed_units" - | "stale_hook_state" - | "activity_log_bloat" - | "state_file_stale" - | "state_file_missing" - | "gitignore_missing_patterns" - | "unresolvable_dependency" - | "failed_migration" - | "broken_symlink" - | "numbered_gsd_variant" - // Environment health checks (#1221) - | "env_node_version" - | "env_dependencies" - | "env_env_file" - | "env_port_conflict" - | "env_disk_space" - | "env_docker" - | "env_package_manager" - | "env_typescript" - | "env_python" - | "env_cargo" - | "env_go" - | "env_git_remote" - // Provider / auth checks - | "provider_key_missing" - | "provider_key_backedoff" - // Lock infrastructure checks - | "stranded_lock_directory" - // Git / worktree integrity checks - | "integration_branch_missing" - | "worktree_directory_orphaned" - // SF state structural checks - | "circular_slice_dependency" - | "orphaned_slice_directory" - | "missing_slice_dir" - | "duplicate_task_id" - | "task_file_not_in_plan" - | "stale_replan_file" - | "future_timestamp" - // Worktree lifecycle checks - | "worktree_branch_merged" - | "worktree_stale" - | "worktree_dirty" - | "worktree_unpushed" - // Stale commit safety check - | "stale_uncommitted_changes" - // Snapshot ref bloat - | "snapshot_ref_bloat" - // Runtime data integrity - | "orphaned_project_state" - | "metrics_ledger_bloat" - | "metrics_ledger_corrupt" - | "large_planning_file" - // Slow environment checks (opt-in via --build / --test flags) - | "env_build" - | "env_test" - // Engine health checks (Phase 4) - | "db_orphaned_task" - | "db_orphaned_slice" - | "db_done_task_no_summary" - | "db_duplicate_id" - | "db_unavailable" - | "projection_drift"; - -/** - * Issue codes that represent global or completion-critical state. - * These must NOT be auto-fixed when fixLevel is "task" — automated - * post-task health checks must never delete external project state directories - * or remove completed-unit keys (which causes state reversion / data loss). - * - * orphaned_completed_units: Removing completed-unit keys causes deriveState to - * consider those tasks incomplete, reverting the user to an earlier slice and - * effectively discarding all work past that point (#1809). This must only be - * fixed by an explicit manual doctor run (fixLevel="all"). - */ -export const GLOBAL_STATE_CODES = new Set<DoctorIssueCode>([ - "orphaned_project_state", - "orphaned_completed_units", -]); - -export interface DoctorIssue { - severity: DoctorSeverity; - code: DoctorIssueCode; - scope: "project" | "milestone" | "slice" | "task"; - unitId: string; - message: string; - file?: string; - fixable: boolean; -} - -export interface DoctorReport { - ok: boolean; - basePath: string; - issues: DoctorIssue[]; - fixesApplied: string[]; - /** Per-domain check durations in milliseconds. Present on explicit /gsd doctor runs. */ - timing?: { git: number; runtime: number; environment: number; gsdState: number }; -} - -export interface DoctorSummary { - total: number; - errors: number; - warnings: number; - infos: number; - fixable: number; - byCode: Array<{ code: DoctorIssueCode; count: number }>; -} diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts deleted file mode 100644 index 76b11a15c..000000000 --- a/src/resources/extensions/gsd/doctor.ts +++ /dev/null @@ -1,813 +0,0 @@ -import { existsSync, mkdirSync, lstatSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { loadFile, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js"; -import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js"; -import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; -import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile, relMilestonePath } from "./paths.js"; -import { deriveState, isMilestoneComplete } from "./state.js"; -import { invalidateAllCaches } from "./cache.js"; -import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js"; -import { isClosedStatus } from "./status-guards.js"; - -import type { DoctorIssue, DoctorIssueCode, DoctorReport } from "./doctor-types.js"; -import { GLOBAL_STATE_CODES } from "./doctor-types.js"; -import type { RoadmapSliceEntry } from "./types.js"; -import { checkGitHealth, checkRuntimeHealth, checkGlobalHealth, checkEngineHealth } from "./doctor-checks.js"; -import { checkEnvironmentHealth } from "./doctor-environment.js"; -import { runProviderChecks } from "./doctor-providers.js"; - -// ── Re-exports ───────────────────────────────────────────────────────────── -// All public types and functions from extracted modules are re-exported here -// so that existing imports from "./doctor.js" continue to work unchanged. -export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js"; -export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt, formatDoctorReportJson } from "./doctor-format.js"; -export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js"; -export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js"; - -/** - * Characters that are used as delimiters in SF state management documents - * and should not appear in milestone or slice titles. - * - * - "\u2014" (em dash, U+2014): used as a display separator in STATE.md and other docs. - * A title containing "\u2014" makes the separator ambiguous, corrupting state display - * and confusing the LLM agent that reads and writes these files. - * - "\u2013" (en dash, U+2013): visually similar to em dash; same ambiguity risk. - * - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01) - * and git branch names (gsd/M001/S01). A slash in a title can break path resolution. - */ -const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash - -/** - * Check whether a milestone or slice title contains characters that conflict - * with SF's state document delimiter conventions. - * Returns a human-readable description of the problem, or null if the title is safe. - */ -export function validateTitle(title: string): string | null { - if (TITLE_DELIMITER_RE.test(title)) { - const found: string[] = []; - if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)"); - if (/\//.test(title)) found.push("forward slash (/)"); - return `title contains ${found.join(" and ")}, which conflict with SF state document delimiters`; - } - return null; -} - -function validatePreferenceShape(preferences: GSDPreferences): string[] { - const issues: string[] = []; - const listFields = ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const; - for (const field of listFields) { - const value = preferences[field]; - if (value !== undefined && !Array.isArray(value)) { - issues.push(`${field} must be a list`); - } - } - - if (preferences.skill_rules !== undefined) { - if (!Array.isArray(preferences.skill_rules)) { - issues.push("skill_rules must be a list"); - } else { - for (const [index, rule] of preferences.skill_rules.entries()) { - if (!rule || typeof rule !== "object") { - issues.push(`skill_rules[${index}] must be an object`); - continue; - } - if (typeof rule.when !== "string") { - issues.push(`skill_rules[${index}].when must be a string`); - } - for (const key of ["use", "prefer", "avoid"] as const) { - const value = (rule as unknown as Record<string, unknown>)[key]; - if (value !== undefined && !Array.isArray(value)) { - issues.push(`skill_rules[${index}].${key} must be a list`); - } - } - } - } - } - - return issues; -} - -/** Build STATE.md content from derived state. Exported for guided-flow pre-dispatch rebuild (#3475). */ -export function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string { - const lines: string[] = []; - lines.push("# SF State", ""); - - const activeMilestone = state.activeMilestone - ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` - : "None"; - const activeSlice = state.activeSlice - ? `${state.activeSlice.id}: ${state.activeSlice.title}` - : "None"; - - lines.push(`**Active Milestone:** ${activeMilestone}`); - lines.push(`**Active Slice:** ${activeSlice}`); - lines.push(`**Phase:** ${state.phase}`); - if (state.requirements) { - lines.push(`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`); - } - lines.push(""); - lines.push("## Milestone Registry"); - - for (const entry of state.registry) { - const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : entry.status === "parked" ? "\u23F8\uFE0F" : "\u2B1C"; - lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); - } - - lines.push(""); - lines.push("## Recent Decisions"); - if (state.recentDecisions.length > 0) { - for (const decision of state.recentDecisions) lines.push(`- ${decision}`); - } else { - lines.push("- None recorded"); - } - - lines.push(""); - lines.push("## Blockers"); - if (state.blockers.length > 0) { - for (const blocker of state.blockers) lines.push(`- ${blocker}`); - } else { - lines.push("- None"); - } - - lines.push(""); - lines.push("## Next Action"); - lines.push(state.nextAction || "None"); - lines.push(""); - - return lines.join("\n"); -} - -async function updateStateFile(basePath: string, fixesApplied: string[]): Promise<void> { - const state = await deriveState(basePath); - const path = resolveGsdRootFile(basePath, "STATE"); - await saveFile(path, buildStateMarkdown(state)); - fixesApplied.push(`updated ${path}`); -} - -/** Rebuild STATE.md from current disk state. Exported for auto-mode post-hooks. */ -export async function rebuildState(basePath: string): Promise<void> { - invalidateAllCaches(); - const state = await deriveState(basePath); - const path = resolveGsdRootFile(basePath, "STATE"); - await saveFile(path, buildStateMarkdown(state)); -} - -function matchesScope(unitId: string, scope?: string): boolean { - if (!scope) return true; - return unitId === scope || unitId.startsWith(`${scope}/`); -} - -function auditRequirements(content: string | null): DoctorIssue[] { - if (!content) return []; - const issues: DoctorIssue[] = []; - const blocks = content.split(/^###\s+/m).slice(1); - - for (const block of blocks) { - const idMatch = block.match(/^(R\d+)/); - if (!idMatch) continue; - const requirementId = idMatch[1]; - const status = block.match(/^-\s+Status:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; - const owner = block.match(/^-\s+Primary owning slice:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; - const notes = block.match(/^-\s+Notes:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; - - if (status === "active" && (!owner || owner === "none" || owner === "none yet")) { - issues.push({ - severity: "error", - code: "active_requirement_missing_owner", - scope: "project", - unitId: requirementId, - message: `${requirementId} is Active but has no primary owning slice`, - file: relGsdRootFile("REQUIREMENTS"), - fixable: false, - }); - } - - if (status === "blocked" && !notes) { - issues.push({ - severity: "warning", - code: "blocked_requirement_missing_reason", - scope: "project", - unitId: requirementId, - message: `${requirementId} is Blocked but has no reason in Notes`, - file: relGsdRootFile("REQUIREMENTS"), - fixable: false, - }); - } - } - - return issues; -} - -export async function selectDoctorScope(basePath: string, requestedScope?: string): Promise<string | undefined> { - if (requestedScope) return requestedScope; - - const state = await deriveState(basePath); - if (state.activeMilestone?.id && state.activeSlice?.id) { - return `${state.activeMilestone.id}/${state.activeSlice.id}`; - } - if (state.activeMilestone?.id) { - return state.activeMilestone.id; - } - - const milestonesPath = milestonesDir(basePath); - if (!existsSync(milestonesPath)) return undefined; - - for (const milestone of state.registry) { - const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (!roadmapContent) continue; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestone.id); - const allDone = dbSlices.length > 0 && dbSlices.every(s => s.status === "complete"); - if (!allDone) return milestone.id; - } else { - const roadmap = parseLegacyRoadmap(roadmapContent); - if (!isMilestoneComplete(roadmap)) return milestone.id; - } - } - - return state.registry[0]?.id; -} - -// ── Helper: circular dependency detection ────────────────────────────────── -function detectCircularDependencies(slices: RoadmapSliceEntry[]): string[][] { - const known = new Set(slices.map(s => s.id)); - const adj = new Map<string, string[]>(); - for (const s of slices) adj.set(s.id, s.depends.filter(d => known.has(d))); - const state = new Map<string, "unvisited" | "visiting" | "done">(); - for (const s of slices) state.set(s.id, "unvisited"); - const cycles: string[][] = []; - function dfs(id: string, path: string[]): void { - const st = state.get(id); - if (st === "done") return; - if (st === "visiting") { cycles.push([...path.slice(path.indexOf(id)), id]); return; } - state.set(id, "visiting"); - for (const dep of adj.get(id) ?? []) dfs(dep, [...path, id]); - state.set(id, "done"); - } - for (const s of slices) if (state.get(s.id) === "unvisited") dfs(s.id, []); - return cycles; -} - -// ── Helper: doctor run history ────────────────────────────────────────────── -export interface DoctorHistoryEntry { - ts: string; - ok: boolean; - errors: number; - warnings: number; - fixes: number; - codes: string[]; - /** Issue messages with severity and scope (added in Phase 2). */ - issues?: Array<{ severity: string; code: string; message: string; unitId: string }>; - /** Fix descriptions applied during this run (added in Phase 2). */ - fixDescriptions?: string[]; - /** Milestone/slice scope this doctor run was scoped to (e.g. "M001/S02"). */ - scope?: string; - /** Human-readable one-line summary of this doctor run. */ - summary?: string; -} - -async function appendDoctorHistory(basePath: string, report: DoctorReport): Promise<void> { - try { - const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl"); - const errorCount = report.issues.filter(i => i.severity === "error").length; - const warningCount = report.issues.filter(i => i.severity === "warning").length; - const issueDetails = report.issues - .filter(i => i.severity === "error" || i.severity === "warning") - .slice(0, 10) // cap to keep JSONL lines bounded - .map(i => ({ severity: i.severity, code: i.code, message: i.message, unitId: i.unitId })); - - // Human-readable one-line summary - const summaryParts: string[] = []; - if (report.ok) { - summaryParts.push("Clean"); - } else { - const counts: string[] = []; - if (errorCount > 0) counts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); - if (warningCount > 0) counts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); - summaryParts.push(counts.join(", ")); - } - if (report.fixesApplied.length > 0) { - summaryParts.push(`${report.fixesApplied.length} fixed`); - } - if (issueDetails.length > 0) { - const topIssue = issueDetails.find(i => i.severity === "error") ?? issueDetails[0]!; - summaryParts.push(topIssue.message); - } - - const entry = JSON.stringify({ - ts: new Date().toISOString(), - ok: report.ok, - errors: errorCount, - warnings: warningCount, - fixes: report.fixesApplied.length, - codes: [...new Set(report.issues.map(i => i.code))], - issues: issueDetails.length > 0 ? issueDetails : undefined, - fixDescriptions: report.fixesApplied.length > 0 ? report.fixesApplied : undefined, - scope: (report as any).scope as string | undefined, - summary: summaryParts.join(" · "), - } satisfies DoctorHistoryEntry); - const existing = existsSync(historyPath) ? readFileSync(historyPath, "utf-8") : ""; - await saveFile(historyPath, existing + entry + "\n"); - } catch { /* non-fatal */ } -} - -/** Read the last N doctor history entries. Returns most-recent-first. */ -export async function readDoctorHistory(basePath: string, lastN = 50): Promise<DoctorHistoryEntry[]> { - try { - const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl"); - if (!existsSync(historyPath)) return []; - const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim()); - return lines.slice(-lastN).reverse().map(l => JSON.parse(l) as DoctorHistoryEntry); - } catch { return []; } -} - -export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; dryRun?: boolean; scope?: string; fixLevel?: "task" | "all"; isolationMode?: "none" | "worktree" | "branch"; includeBuild?: boolean; includeTests?: boolean }): Promise<DoctorReport> { - const issues: DoctorIssue[] = []; - const fixesApplied: string[] = []; - const fix = options?.fix === true; - const dryRun = options?.dryRun === true; - const fixLevel = options?.fixLevel ?? "all"; - - // Issue codes that represent completion state transitions — creating summary - // stubs, marking slices/milestones done in the roadmap. These belong to the - // dispatch lifecycle (complete-slice, complete-milestone units), not to - // mechanical post-hook bookkeeping. When fixLevel is "task", these are - // detected and reported but never auto-fixed. - - /** Whether a given issue code should be auto-fixed at the current fixLevel. */ - const shouldFix = (code: DoctorIssueCode): boolean => { - if (!fix || dryRun) return false; - if (fixLevel === "task" && GLOBAL_STATE_CODES.has(code)) return false; - return true; - }; - - const prefs = loadEffectiveGSDPreferences(); - if (prefs) { - const prefIssues = validatePreferenceShape(prefs.preferences); - for (const issue of prefIssues) { - issues.push({ - severity: "warning", - code: "invalid_preferences", - scope: "project", - unitId: "project", - message: `SF preferences invalid: ${issue}`, - file: prefs.path, - fixable: false, - }); - } - } - - // Git health checks — timed - const t0git = Date.now(); - const isolationMode: "none" | "worktree" | "branch" = options?.isolationMode ?? - (prefs?.preferences?.git?.isolation === "worktree" ? "worktree" : - prefs?.preferences?.git?.isolation === "branch" ? "branch" : "none"); - await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode); - const gitMs = Date.now() - t0git; - - // Runtime health checks — timed - const t0runtime = Date.now(); - await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix); - const runtimeMs = Date.now() - t0runtime; - - // Global health checks — cross-project state (e.g. orphaned project state dirs) - await checkGlobalHealth(issues, fixesApplied, shouldFix); - - // Environment health checks — timed - const t0env = Date.now(); - await checkEnvironmentHealth(basePath, issues, { - includeRemote: !options?.scope, - includeBuild: options?.includeBuild, - includeTests: options?.includeTests, - }); - const envMs = Date.now() - t0env; - - // Engine health checks — DB constraints and projection drift - await checkEngineHealth(basePath, issues, fixesApplied); - - const milestonesPath = milestonesDir(basePath); - if (!existsSync(milestonesPath)) { - const report: DoctorReport = { ok: issues.every(i => i.severity !== "error"), basePath, issues, fixesApplied, timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: 0 } }; - await appendDoctorHistory(basePath, report); - return report; - } - - const requirementsPath = resolveGsdRootFile(basePath, "REQUIREMENTS"); - const requirementsContent = await loadFile(requirementsPath); - issues.push(...auditRequirements(requirementsContent)); - - const state = await deriveState(basePath); - - // Provider / auth health checks — only relevant when there is active work to dispatch. - // Skipped for idle projects (no active milestone) to avoid noise in environments - // where CI/test runners have no API key configured. - if (state.activeMilestone) { - try { - const providerResults = runProviderChecks(); - for (const result of providerResults) { - if (!result.required) continue; - if (result.status === "error") { - issues.push({ - severity: "warning", - code: "provider_key_missing", - scope: "project", - unitId: "project", - message: result.message + (result.detail ? ` — ${result.detail}` : ""), - fixable: false, - }); - } else if (result.status === "warning") { - issues.push({ - severity: "warning", - code: "provider_key_backedoff", - scope: "project", - unitId: "project", - message: result.message + (result.detail ? ` — ${result.detail}` : ""), - fixable: false, - }); - } - } - } catch { - // Non-fatal — provider check failure should not block other checks - } - } - - for (const milestone of state.registry) { - const milestoneId = milestone.id; - const milestonePath = resolveMilestonePath(basePath, milestoneId); - if (!milestonePath) continue; - - // Validate milestone title for delimiter characters that break state documents. - const milestoneTitleIssue = validateTitle(milestone.title); - if (milestoneTitleIssue) { - const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - let wasFixed = false; - if (shouldFix("delimiter_in_title") && roadmapFile) { - try { - const raw = readFileSync(roadmapFile, "utf-8"); - // Replace em/en dashes with " - " in the H1 title line only - const sanitized = raw.replace(/^(# .*)$/m, (line) => - line.replace(/[\u2014\u2013]/g, "-"), - ); - if (sanitized !== raw) { - await saveFile(roadmapFile, sanitized); - fixesApplied.push(`sanitized delimiter characters in ${milestoneId} title`); - wasFixed = true; - } - } catch { /* non-fatal — report the warning below */ } - } - if (!wasFixed) { - issues.push({ - severity: "warning", - code: "delimiter_in_title", - scope: "milestone", - unitId: milestoneId, - message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: true, - }); - } - } - - const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); - const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null; - if (!roadmapContent) continue; - - // Normalize slices: prefer DB, fall back to parser - type NormSlice = RoadmapSliceEntry & { pending?: boolean; skipped?: boolean }; - let slices: NormSlice[]; - if (isDbAvailable()) { - const dbSlices = getMilestoneSlices(milestoneId); - slices = dbSlices.map(s => ({ - id: s.id, - title: s.title, - done: isClosedStatus(s.status), - pending: s.status === "pending", - skipped: s.status === "skipped", - risk: (s.risk || "medium") as RoadmapSliceEntry["risk"], - depends: s.depends, - demo: s.demo, - })); - } else { - const activeMilestoneId = state.activeMilestone?.id; - const activeSliceId = state.activeSlice?.id; - slices = parseLegacyRoadmap(roadmapContent).slices.map(s => ({ - ...s, - // Legacy roadmaps only encode done vs not-done. For doctor's - // missing-directory checks, treat every undone slice except the - // current active slice as effectively pending/unstarted. - pending: !s.done && (milestoneId !== activeMilestoneId || s.id !== activeSliceId), - })); - } - // Wrap in Roadmap-compatible shape for detectCircularDependencies - const roadmap = { slices }; - - // ── Circular dependency detection ────────────────────────────────────── - for (const cycle of detectCircularDependencies(roadmap.slices)) { - issues.push({ - severity: "error", - code: "circular_slice_dependency", - scope: "milestone", - unitId: milestoneId, - message: `Circular dependency detected: ${cycle.join(" → ")}`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - - // ── Orphaned slice directories ───────────────────────────────────────── - try { - const slicesDir = join(milestonePath, "slices"); - if (existsSync(slicesDir)) { - const knownSliceIds = new Set(roadmap.slices.map(s => s.id)); - for (const entry of readdirSync(slicesDir)) { - try { - if (!lstatSync(join(slicesDir, entry)).isDirectory()) continue; - } catch { continue; } - if (!knownSliceIds.has(entry)) { - issues.push({ - severity: "warning", - code: "orphaned_slice_directory", - scope: "milestone", - unitId: milestoneId, - message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap`, - file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`, - fixable: false, - }); - } - } - } - } catch { /* non-fatal */ } - - for (const slice of roadmap.slices) { - const unitId = `${milestoneId}/${slice.id}`; - if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue; - - // Validate slice title for delimiter characters. - const sliceTitleIssue = validateTitle(slice.title); - if (sliceTitleIssue) { - // Slice titles live inside the roadmap H1/checkbox lines — the milestone-level - // fix above already sanitizes the roadmap file. For slices we only report, because - // the title comes from the checkbox text and requires careful regex to fix safely. - issues.push({ - severity: "warning", - code: "delimiter_in_title", - scope: "slice", - unitId, - message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - - // Check for unresolvable dependency IDs - const knownSliceIds = new Set(roadmap.slices.map(s => s.id)); - for (const dep of slice.depends) { - if (!knownSliceIds.has(dep)) { - issues.push({ - severity: "warning", - code: "unresolvable_dependency", - scope: "slice", - unitId, - message: `Slice ${unitId} depends on "${dep}" which is not a slice ID in this roadmap. This permanently blocks the slice. Use comma-separated IDs: \`depends:[S01,S02]\``, - file: relMilestoneFile(basePath, milestoneId, "ROADMAP"), - fixable: false, - }); - } - } - - const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); - if (!slicePath) { - // Pending slices haven't been planned yet — directories are created - // lazily by ensurePreconditions() at dispatch time. Skipped slices are - // intentionally allowed to remain summary-less and directory-less. - if (slice.pending || slice.skipped) continue; - const expectedPath = relSlicePath(basePath, milestoneId, slice.id); - issues.push({ - severity: slice.done ? "warning" : "error", - code: "missing_slice_dir", - scope: "slice", - unitId, - message: slice.done - ? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)` - : `Missing slice directory for ${unitId}`, - file: expectedPath, - fixable: true, - }); - if (fix) { - const absoluteSliceDir = join(milestonePath, "slices", slice.id); - mkdirSync(absoluteSliceDir, { recursive: true }); - fixesApplied.push(`created ${absoluteSliceDir}`); - } - continue; - } - - const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); - if (!tasksDir) { - // Pending slices haven't been planned yet — tasks/ is created on demand. - // Skipped slices may legitimately never create tasks/. - if (slice.pending || slice.skipped) continue; - issues.push({ - severity: slice.done ? "warning" : "error", - code: "missing_tasks_dir", - scope: "slice", - unitId, - message: slice.done - ? `Missing tasks directory for ${unitId} (slice is complete \u2014 cosmetic only)` - : `Missing tasks directory for ${unitId}`, - file: relSlicePath(basePath, milestoneId, slice.id), - fixable: true, - }); - if (fix) { - mkdirSync(join(slicePath, "tasks"), { recursive: true }); - fixesApplied.push(`created ${join(slicePath, "tasks")}`); - } - } - - const planPath = resolveSliceFile(basePath, milestoneId, slice.id, "PLAN"); - const planContent = planPath ? await loadFile(planPath) : null; - // Normalize plan tasks: prefer DB, fall back to parsers-legacy - let plan: { tasks: Array<{ id: string; done: boolean; title: string; estimate?: string }> } | null = null; - if (isDbAvailable()) { - const dbTasks = getSliceTasks(milestoneId, slice.id); - if (dbTasks.length > 0) { - plan = { tasks: dbTasks.map(t => ({ id: t.id, done: t.status === "complete" || t.status === "done", title: t.title, estimate: t.estimate || undefined })) }; - } - } - if (!plan && planContent) { - plan = parseLegacyPlan(planContent); - } - if (!plan) { - if (!slice.done) { - issues.push({ - severity: "warning", - code: "missing_slice_plan", - scope: "slice", - unitId, - message: `Slice ${unitId} has no plan file`, - file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), - fixable: false, - }); - } - continue; - } - - // ── Duplicate task IDs ─────────────────────────────────────────────── - const taskIdCounts = new Map<string, number>(); - for (const task of plan.tasks) taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1); - for (const [taskId, count] of taskIdCounts) { - if (count > 1) { - issues.push({ severity: "error", code: "duplicate_task_id", scope: "slice", unitId, - message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`, - file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), fixable: false }); - } - } - - // ── Task files on disk not in plan ──────────────────────────────────── - try { - if (tasksDir) { - const planTaskIds = new Set(plan.tasks.map(t => t.id)); - for (const f of readdirSync(tasksDir)) { - if (!f.endsWith("-SUMMARY.md")) continue; - const diskTaskId = f.replace(/-SUMMARY\.md$/, ""); - if (!planTaskIds.has(diskTaskId)) { - issues.push({ severity: "info", code: "task_file_not_in_plan", scope: "slice", unitId, - message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`, - file: relTaskFile(basePath, milestoneId, slice.id, diskTaskId, "SUMMARY"), fixable: false }); - } - } - } - } catch { /* non-fatal */ } - - let allTasksDone = plan.tasks.length > 0; - for (const task of plan.tasks) { - const taskUnitId = `${unitId}/${task.id}`; - const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); - const hasSummary = !!(summaryPath && await loadFile(summaryPath)); - - // Must-have verification - if (task.done && hasSummary) { - const taskPlanPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "PLAN"); - if (taskPlanPath) { - const taskPlanContent = await loadFile(taskPlanPath); - if (taskPlanContent) { - const mustHaves = parseTaskPlanMustHaves(taskPlanContent); - if (mustHaves.length > 0) { - const summaryContent = await loadFile(summaryPath!); - const mentionedCount = summaryContent - ? countMustHavesMentionedInSummary(mustHaves, summaryContent) - : 0; - if (mentionedCount < mustHaves.length) { - issues.push({ - severity: "warning", - code: "task_done_must_haves_not_verified", - scope: "task", - unitId: taskUnitId, - message: `Task ${task.id} has ${mustHaves.length} must-haves but summary addresses only ${mentionedCount}`, - file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), - fixable: false, - }); - } - } - } - } - } - - // ── Future timestamp check ───────────────────────────────────── - if (task.done && hasSummary && summaryPath) { - try { - const rawSummary = await loadFile(summaryPath); - const m = rawSummary?.match(/^completed_at:\s*(.+)$/m); - if (m) { - const ts = new Date(m[1].trim()); - if (!isNaN(ts.getTime()) && ts.getTime() > Date.now() + 24 * 60 * 60 * 1000) { - issues.push({ severity: "warning", code: "future_timestamp", scope: "task", unitId: taskUnitId, - message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`, - file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), fixable: false }); - } - } - } catch { /* non-fatal */ } - } - - allTasksDone = allTasksDone && task.done; - } - - // Blocker-without-replan detection - // Skip when all tasks are done — the blocker was implicitly resolved - // within the task and the slice is not stuck (#3105 Bug 2). - const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN"); - if (!replanPath && !allTasksDone) { - for (const task of plan.tasks) { - if (!task.done) continue; - const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"); - if (!summaryPath) continue; - const summaryContent = await loadFile(summaryPath); - if (!summaryContent) continue; - const summary = parseSummary(summaryContent); - if (summary.frontmatter.blocker_discovered) { - issues.push({ - severity: "warning", - code: "blocker_discovered_no_replan", - scope: "slice", - unitId, - message: `Task ${task.id} reported blocker_discovered but no REPLAN.md exists for ${slice.id} \u2014 slice may be stuck`, - file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), - fixable: false, - }); - break; - } - } - } - - // ── Stale REPLAN: exists but all tasks done ──────────────────────── - if (replanPath && allTasksDone) { - issues.push({ severity: "info", code: "stale_replan_file", scope: "slice", unitId, - message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`, - file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), fixable: false }); - } - - } - - // Milestone-level check: all slices done but no validation file - const milestoneComplete = roadmap.slices.length > 0 && roadmap.slices.every(s => s.done); - if (milestoneComplete && !resolveMilestoneFile(basePath, milestoneId, "VALIDATION") && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) { - issues.push({ - severity: "info", - code: "all_slices_done_missing_milestone_validation", - scope: "milestone", - unitId: milestoneId, - message: `All slices are done but ${milestoneId}-VALIDATION.md is missing \u2014 milestone is in validating-milestone phase`, - file: relMilestoneFile(basePath, milestoneId, "VALIDATION"), - fixable: false, - }); - } - - // Milestone-level check: all slices done but no milestone summary - if (milestoneComplete && !resolveMilestoneFile(basePath, milestoneId, "SUMMARY")) { - issues.push({ - severity: "warning", - code: "all_slices_done_missing_milestone_summary", - scope: "milestone", - unitId: milestoneId, - message: `All slices are done but ${milestoneId}-SUMMARY.md is missing \u2014 milestone is stuck in completing-milestone phase`, - file: relMilestoneFile(basePath, milestoneId, "SUMMARY"), - fixable: false, - }); - } - } - - if (fix && !dryRun && fixesApplied.length > 0) { - await updateStateFile(basePath, fixesApplied); - } - - const report: DoctorReport = { - ok: issues.every(issue => issue.severity !== "error"), - basePath, - issues, - fixesApplied, - timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: Math.max(0, Date.now() - t0env - envMs) }, - }; - await appendDoctorHistory(basePath, report); - return report; -} diff --git a/src/resources/extensions/gsd/engine-resolver.ts b/src/resources/extensions/gsd/engine-resolver.ts deleted file mode 100644 index 013e82515..000000000 --- a/src/resources/extensions/gsd/engine-resolver.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * engine-resolver.ts — Route sessions to engine/policy pairs. - * - * Routes `null` and `"dev"` engine IDs to the DevWorkflowEngine/DevExecutionPolicy - * pair. Any other non-null engine ID is treated as a custom workflow engine that - * reads its state from an `activeRunDir`. Respects `SF_ENGINE_BYPASS=1` kill - * switch to skip the engine layer entirely. - */ - -import type { WorkflowEngine } from "./workflow-engine.js"; -import type { ExecutionPolicy } from "./execution-policy.js"; -import { DevWorkflowEngine } from "./dev-workflow-engine.js"; -import { DevExecutionPolicy } from "./dev-execution-policy.js"; -import { CustomWorkflowEngine } from "./custom-workflow-engine.js"; -import { CustomExecutionPolicy } from "./custom-execution-policy.js"; - -/** A resolved engine + policy pair ready for the auto-loop. */ -export interface ResolvedEngine { - engine: WorkflowEngine; - policy: ExecutionPolicy; -} - -/** - * Resolve an engine/policy pair for the given session. - * - * - `null` or `"dev"` → DevWorkflowEngine + DevExecutionPolicy - * - any other non-null ID → CustomWorkflowEngine(activeRunDir) + CustomExecutionPolicy() - * (requires activeRunDir to be a non-empty string) - * - * Note: `SF_ENGINE_BYPASS=1` is checked in autoLoop before calling this function. - */ -export function resolveEngine( - session: { activeEngineId: string | null; activeRunDir?: string | null }, -): ResolvedEngine { - const { activeEngineId, activeRunDir } = session; - - if (activeEngineId === null || activeEngineId === "dev") { - return { - engine: new DevWorkflowEngine(), - policy: new DevExecutionPolicy(), - }; - } - - // Any non-null, non-"dev" engine ID is a custom workflow engine. - // activeRunDir is required — the engine reads GRAPH.yaml from it. - if (!activeRunDir || typeof activeRunDir !== "string") { - throw new Error( - `Custom engine "${activeEngineId}" requires activeRunDir to be a non-empty string, ` + - `got: ${JSON.stringify(activeRunDir)}`, - ); - } - - return { - engine: new CustomWorkflowEngine(activeRunDir), - policy: new CustomExecutionPolicy(activeRunDir), - }; -} diff --git a/src/resources/extensions/gsd/engine-types.ts b/src/resources/extensions/gsd/engine-types.ts deleted file mode 100644 index ea63cfa63..000000000 --- a/src/resources/extensions/gsd/engine-types.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * engine-types.ts — Engine-polymorphic type contracts. - * - * LEAF NODE: This file must have ZERO imports from any SF module. - * Only `node:` imports are permitted. All engine/policy interfaces - * depend on these types; nothing here depends on SF internals. - */ - -/** Snapshot of engine state at a point in time. */ -export interface EngineState { - phase: string; - currentMilestoneId: string | null; - activeSliceId: string | null; - activeTaskId: string | null; - isComplete: boolean; - /** Opaque engine-specific state — never narrowed to a SF-specific type. */ - raw: unknown; -} - -/** A unit of work the engine wants the agent to execute. */ -export interface StepContract { - unitType: string; - unitId: string; - prompt: string; -} - -/** UI-facing metadata for progress display. */ -export interface DisplayMetadata { - engineLabel: string; - currentPhase: string; - progressSummary: string; - stepCount: { completed: number; total: number } | null; -} - -/** - * Discriminated union: what the engine tells the loop to do next. - * - * - `dispatch` — execute a step - * - `stop` — halt the loop with a reason and severity - * - `skip` — nothing to do right now, advance without executing - */ -export type EngineDispatchAction = - | { action: "dispatch"; step: StepContract } - | { action: "stop"; reason: string; level: "info" | "warning" | "error" } - | { action: "skip" }; - -/** Outcome of reconciling state after a step completes. */ -export interface ReconcileResult { - outcome: "continue" | "milestone-complete" | "pause" | "stop"; - reason?: string; -} - -/** Recovery strategy when a step fails. */ -export interface RecoveryAction { - outcome: "retry" | "skip" | "stop" | "pause"; - reason?: string; -} - -/** Result of closing out a completed unit. */ -export interface CloseoutResult { - committed: boolean; - artifacts: string[]; -} - -/** Record of a completed execution step. */ -export interface CompletedStep { - unitType: string; - unitId: string; - startedAt: number; - finishedAt: number; -} diff --git a/src/resources/extensions/gsd/env-utils.ts b/src/resources/extensions/gsd/env-utils.ts deleted file mode 100644 index 06f69a8d5..000000000 --- a/src/resources/extensions/gsd/env-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SF Extension — Environment variable utilities -// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net> -// -// Pure utility for checking existing env keys in .env files and process.env. -// Extracted from get-secrets-from-user.ts to avoid pulling in @sf-run/pi-tui -// when only env-checking is needed (e.g. from files.ts during report generation). - -import { readFile } from "node:fs/promises"; - -/** - * Check which keys already exist in a .env file or process.env. - * Returns the subset of `keys` that are already set. - */ -export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> { - let fileContent = ""; - try { - fileContent = await readFile(envFilePath, "utf8"); - } catch { - // ENOENT or other read error — proceed with empty content - } - - const existing: string[] = []; - for (const key of keys) { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(`^${escaped}\\s*=`, "m"); - if (regex.test(fileContent) || key in process.env) { - existing.push(key); - } - } - return existing; -} diff --git a/src/resources/extensions/gsd/error-classifier.ts b/src/resources/extensions/gsd/error-classifier.ts deleted file mode 100644 index 33fad43eb..000000000 --- a/src/resources/extensions/gsd/error-classifier.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Error classifier for provider/network/server failures. - * - * Consolidates patterns from: - * - isTransientNetworkError() in preferences-models.ts - * - classifyProviderError() in provider-error-pause.ts - * - * Single entry point: classifyError(errorMsg, retryAfterMs?) - * - * @see https://github.com/gsd-build/gsd/issues/2577 - */ - -// ── ErrorClass discriminated union ────────────────────────────────────────── - -export type ErrorClass = - | { kind: "network"; retryAfterMs: number } - | { kind: "rate-limit"; retryAfterMs: number } - | { kind: "server"; retryAfterMs: number } - | { kind: "stream"; retryAfterMs: number } - | { kind: "connection"; retryAfterMs: number } - | { kind: "model-error" } - | { kind: "permanent" } - | { kind: "unknown" }; - -// ── RetryState ────────────────────────────────────────────────────────────── - -export interface RetryState { - networkRetryCount: number; - consecutiveTransientCount: number; - currentRetryModelId: string | undefined; -} - -export function createRetryState(): RetryState { - return { networkRetryCount: 0, consecutiveTransientCount: 0, currentRetryModelId: undefined }; -} - -export function resetRetryState(state: RetryState): void { - state.networkRetryCount = 0; - state.consecutiveTransientCount = 0; - state.currentRetryModelId = undefined; -} - -// ── Classification ────────────────────────────────────────────────────────── - -const PERMANENT_RE = /auth|unauthorized|forbidden|invalid.*key|invalid.*api|billing|quota exceeded|account/i; -const RATE_LIMIT_RE = /rate.?limit|too many requests|429/i; -// OpenRouter affordability-style quota errors should be treated as transient -// so core retry logic can lower maxTokens and continue in-session. -const AFFORDABILITY_RE = /requires more credits|can only afford|insufficient credits|not enough credits|fewer max_tokens/i; -const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns/i; -const SERVER_RE = /internal server error|500|502|503|overloaded|server_error|api_error|service.?unavailable/i; -// ECONNRESET/ECONNREFUSED are in NETWORK_RE (same-model retry first). -const CONNECTION_RE = /terminated|connection.?(?:refused|error)|other side closed|EPIPE|network.?(?:is\s+)?unavailable|stream_exhausted(?:_without_result)?/i; -// Catch-all for V8 JSON.parse errors: all modern variants end with "in JSON at position \d+". -// This eliminates the need to enumerate every error message variant individually. -const STREAM_RE = /in JSON at position \d+|Unexpected end of JSON|SyntaxError.*JSON/i; -const RESET_DELAY_RE = /reset in (\d+)s/i; - -/** - * Classify an error message into one of the ErrorClass kinds. - * - * Classification order: - * 1. Permanent (auth/billing/quota) — unless also rate-limited - * 2. Rate limit (429, rate.?limit, too many requests) - * 3. Network (ECONNRESET, ETIMEDOUT, socket hang up, fetch failed, dns) - * 4. Stream truncation (malformed JSON from mid-stream cut) - * 5. Server (500/502/503, overloaded, server_error) - * 6. Connection (terminated, ECONNREFUSED, EPIPE, other side closed) - * 7. Unknown - */ -export function classifyError(errorMsg: string, retryAfterMs?: number): ErrorClass { - const isPermanent = PERMANENT_RE.test(errorMsg); - const isRateLimit = RATE_LIMIT_RE.test(errorMsg) || AFFORDABILITY_RE.test(errorMsg); - - // 1. Permanent — but rate limit takes precedence - if (isPermanent && !isRateLimit) { - return { kind: "permanent" }; - } - - // 2. Rate limit - if (isRateLimit) { - if (retryAfterMs != null && retryAfterMs > 0) { - return { kind: "rate-limit", retryAfterMs }; - } - const resetMatch = errorMsg.match(RESET_DELAY_RE); - const delayMs = resetMatch ? Number(resetMatch[1]) * 1000 : 60_000; - return { kind: "rate-limit", retryAfterMs: delayMs }; - } - - // 3. Network errors — same-model retry candidate - if (NETWORK_RE.test(errorMsg)) { - // Exclude if also matches permanent signals (already handled above for - // rate-limit, but double-check for non-rate-limit permanent overlap like - // "billing" appearing alongside "network"). - return { kind: "network", retryAfterMs: retryAfterMs ?? 3_000 }; - } - - // 4. Stream truncation — downstream symptom of connection drop - if (STREAM_RE.test(errorMsg)) { - return { kind: "stream", retryAfterMs: retryAfterMs ?? 15_000 }; - } - - // 5. Server errors — try fallback model - if (SERVER_RE.test(errorMsg)) { - return { kind: "server", retryAfterMs: retryAfterMs ?? 30_000 }; - } - - // 6. Connection errors — try fallback model - if (CONNECTION_RE.test(errorMsg)) { - return { kind: "connection", retryAfterMs: retryAfterMs ?? 15_000 }; - } - - // 7. Unknown - return { kind: "unknown" }; -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** Returns true for all transient (auto-resumable) error kinds. */ -export function isTransient(cls: ErrorClass): boolean { - switch (cls.kind) { - case "network": - case "rate-limit": - case "server": - case "stream": - case "connection": - return true; - default: - return false; - } -} - -/** - * Backward-compatible thin wrapper. - * - * Returns true when the error is a transient *network* error specifically - * (worth retrying the same model). Permanent signals (auth, billing, quota) - * cause this to return false even if a network keyword is present. - */ -export function isTransientNetworkError(errorMsg: string): boolean { - if (!errorMsg) return false; - const cls = classifyError(errorMsg); - return cls.kind === "network"; -} diff --git a/src/resources/extensions/gsd/error-utils.ts b/src/resources/extensions/gsd/error-utils.ts deleted file mode 100644 index b01f17494..000000000 --- a/src/resources/extensions/gsd/error-utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extract a human-readable message from an unknown caught value. - */ -export function getErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} diff --git a/src/resources/extensions/gsd/errors.ts b/src/resources/extensions/gsd/errors.ts deleted file mode 100644 index 82653042d..000000000 --- a/src/resources/extensions/gsd/errors.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * SF Error Types — Typed error hierarchy for diagnostics and crash recovery. - * - * All SF-specific errors extend GSDError, which carries a stable `code` - * string suitable for programmatic matching. Error codes are defined as - * constants so callers can switch on them without string-matching. - */ - -// ─── Error Codes ────────────────────────────────────────────────────────────── - -export const SF_STALE_STATE = "SF_STALE_STATE"; -export const SF_LOCK_HELD = "SF_LOCK_HELD"; -export const SF_ARTIFACT_MISSING = "SF_ARTIFACT_MISSING"; -export const SF_GIT_ERROR = "SF_GIT_ERROR"; -export const SF_MERGE_CONFLICT = "SF_MERGE_CONFLICT"; -export const SF_PARSE_ERROR = "SF_PARSE_ERROR"; -export const SF_IO_ERROR = "SF_IO_ERROR"; - -// ─── Base Error ─────────────────────────────────────────────────────────────── - -export class GSDError extends Error { - readonly code: string; - - constructor(code: string, message: string, options?: ErrorOptions) { - super(message, options); - this.name = "GSDError"; - this.code = code; - } -} diff --git a/src/resources/extensions/gsd/execution-policy.ts b/src/resources/extensions/gsd/execution-policy.ts deleted file mode 100644 index 21b66763d..000000000 --- a/src/resources/extensions/gsd/execution-policy.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * execution-policy.ts — ExecutionPolicy interface. - * - * Defines the policy layer that governs model selection, verification, - * recovery, and closeout for each execution step. Imports only from - * the leaf-node engine-types. - */ - -import type { RecoveryAction, CloseoutResult } from "./engine-types.js"; - -/** Policy governing how each step is executed, verified, and closed out. */ -export interface ExecutionPolicy { - /** Prepare the workspace before a milestone begins (e.g. worktree setup). */ - prepareWorkspace(basePath: string, milestoneId: string): Promise<void>; - - /** Select the model tier for a given unit. Returns null to use defaults. */ - selectModel( - unitType: string, - unitId: string, - context: { basePath: string }, - ): Promise<{ tier: string; modelDowngraded: boolean } | null>; - - /** Verify unit output. Returns disposition for the loop. */ - verify( - unitType: string, - unitId: string, - context: { basePath: string }, - ): Promise<"continue" | "retry" | "pause">; - - /** Determine recovery action when a unit fails. */ - recover( - unitType: string, - unitId: string, - context: { basePath: string }, - ): Promise<RecoveryAction>; - - /** Close out a completed unit (commit, snapshot, artifact capture). */ - closeout( - unitType: string, - unitId: string, - context: { basePath: string; startedAt: number }, - ): Promise<CloseoutResult>; -} diff --git a/src/resources/extensions/gsd/exit-command.ts b/src/resources/extensions/gsd/exit-command.ts deleted file mode 100644 index 6a1340c35..000000000 --- a/src/resources/extensions/gsd/exit-command.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@sf-run/pi-coding-agent"; - -type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise<void>; - -export function registerExitCommand( - pi: ExtensionAPI, - deps: { stopAuto?: StopAutoFn } = {}, -): void { - pi.registerCommand("exit", { - description: "Exit SF gracefully", - handler: async (_args: string, ctx: ExtensionCommandContext) => { - // Stop auto-mode first so locks and activity state are cleaned up before shutdown. - // Wrapped in try/catch: if sf-run was updated on disk mid-session, the dynamic - // import may resolve a new auto-worktree.js whose static imports reference - // exports absent from the process-cached native-git-bridge.js (ESM cache is - // immutable). The user's work is already saved — this is cleanup only. - try { - const stopAuto = deps.stopAuto ?? (await importExtensionModule<typeof import("./auto.js")>(import.meta.url, "./auto.js")).stopAuto; - await stopAuto(ctx, pi, "Graceful exit"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - ctx.ui?.notify?.( - `Auto-mode cleanup skipped (module version mismatch): ${msg}`, - "warning", - ); - } - ctx.shutdown(); - }, - }); -} diff --git a/src/resources/extensions/gsd/export-html.ts b/src/resources/extensions/gsd/export-html.ts deleted file mode 100644 index 6b24c280f..000000000 --- a/src/resources/extensions/gsd/export-html.ts +++ /dev/null @@ -1,1408 +0,0 @@ -/** - * SF HTML Report Generator - * - * Produces a single self-contained HTML file with: - * - Branding header (project name, path, SF version, generated timestamp) - * - Project summary & overall progress - * - Progress tree (milestones → slices → tasks, with critical path) - * - Execution timeline (chronological unit history) - * - Slice dependency graph (SVG DAG per milestone) - * - Cost & token metrics (bar charts, phase/slice/model/tier breakdowns) - * - Health & configuration overview - * - Changelog (completed slice summaries + file modifications) - * - Knowledge base (rules, patterns, lessons) - * - Captures log - * - Artifacts & milestone planning / discussion state - * - * No external dependencies — all CSS and JS is inlined. - * Printable to PDF from any browser. - * - * Design: Linear-inspired — restrained palette, geometric status, no emoji. - */ - -import type { - VisualizerData, - VisualizerMilestone, - VisualizerSlice, -} from './visualizer-data.js'; -import { formatDateShort, formatDuration } from '../shared/format-utils.js'; -import { formatCost, formatTokenCount } from './metrics.js'; -import type { UnitMetrics } from './metrics.js'; - -// ─── Public API ──────────────────────────────────────────────────────────────── - -export interface HtmlReportOptions { - projectName: string; - projectPath: string; - gsdVersion: string; - milestoneId?: string; - indexRelPath?: string; -} - -export function generateHtmlReport( - data: VisualizerData, - opts: HtmlReportOptions, -): string { - const generated = new Date().toISOString(); - - const sections = [ - buildSummarySection(data, opts, generated), - buildBlockersSection(data), - buildProgressSection(data), - buildTimelineSection(data), - buildDepGraphSection(data), - buildMetricsSection(data), - buildHealthSection(data), - buildChangelogSection(data), - buildKnowledgeSection(data), - buildCapturesSection(data), - buildStatsSection(data), - buildDiscussionSection(data), - ]; - - const milestoneTag = opts.milestoneId - ? ` <span class="sep">/</span> <span class="mono accent">${esc(opts.milestoneId)}</span>` - : ''; - - const backLink = opts.indexRelPath - ? `<a class="back-link" href="${esc(opts.indexRelPath)}">All Reports</a>` - : ''; - - return `<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="UTF-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>SF Report — ${esc(opts.projectName)}${opts.milestoneId ? ` — ${esc(opts.milestoneId)}` : ''} - - - -
-
-
- - v${esc(opts.gsdVersion)} -
-
-

${esc(opts.projectName)}${milestoneTag}

- ${esc(opts.projectPath)} -
-
- ${backLink} -
${formatDateLong(generated)}
-
-
-
- -
-${sections.join('\n')} -
-
- -
- - -`; -} - -// ─── Section: Summary ───────────────────────────────────────────────────────── - -function buildSummarySection( - data: VisualizerData, - opts: HtmlReportOptions, - _generated: string, -): string { - const t = data.totals; - const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); - const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); - const doneMilestones = data.milestones.filter(m => m.status === 'complete').length; - const activeMilestone = data.milestones.find(m => m.status === 'active'); - const pct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0; - - const act = data.agentActivity; - const kv = [ - kvi('Milestones', `${doneMilestones}/${data.milestones.length}`), - kvi('Slices', `${doneSlices}/${totalSlices}`), - kvi('Phase', data.phase), - t ? kvi('Cost', formatCost(t.cost)) : '', - t ? kvi('Tokens', formatTokenCount(t.tokens.total)) : '', - t ? kvi('Duration', formatDuration(t.duration)) : '', - t ? kvi('Tool calls', String(t.toolCalls)) : '', - t ? kvi('Units', String(t.units)) : '', - data.remainingSliceCount > 0 ? kvi('Remaining', String(data.remainingSliceCount)) : '', - act ? kvi('Rate', `${act.completionRate.toFixed(1)}/hr`) : '', - t && doneSlices > 0 ? kvi('Cost/slice', formatCost(t.cost / doneSlices)) : '', - t && t.toolCalls > 0 ? kvi('Tokens/tool', formatTokenCount(t.tokens.total / t.toolCalls)) : '', - t && (t.tokens.input + t.tokens.cacheRead) > 0 - ? kvi('Cache hit', ((t.tokens.cacheRead / (t.tokens.input + t.tokens.cacheRead)) * 100).toFixed(1) + '%') - : '', - opts.milestoneId ? kvi('Scope', opts.milestoneId) : '', - ].filter(Boolean).join(''); - - const activeInfo = activeMilestone ? (() => { - const active = activeMilestone.slices.find(s => s.active); - if (!active) return ''; - return `
- Executing ${esc(activeMilestone.id)}/${esc(active.id)} — ${esc(active.title)} -
`; - })() : ''; - - const activityHtml = act?.active ? ` -
- - ${esc(act.currentUnit?.type ?? '')} - ${esc(act.currentUnit?.id ?? '')} - ${formatDuration(act.elapsed)} elapsed -
` : ''; - - const execSummary = buildExecutiveSummary(data, opts); - const etaLine = buildEtaLine(data); - - return section('summary', 'Summary', ` - ${execSummary} -
${kv}
-
-
- ${pct}% -
- ${activeInfo} - ${activityHtml} - ${etaLine} - `); -} - -function buildExecutiveSummary(data: VisualizerData, opts: HtmlReportOptions): string { - const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); - const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); - const pct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0; - const spent = data.totals?.cost ?? 0; - const activeMilestone = data.milestones.find(m => m.status === 'active'); - const activeSlice = activeMilestone?.slices.find(s => s.active); - const currentExec = activeMilestone && activeSlice - ? ` Currently executing ${esc(activeMilestone.id)}/${esc(activeSlice.id)}.` - : ''; - const budgetCtx = data.health.budgetCeiling - ? ` Budget: ${formatCost(spent)} of ${formatCost(data.health.budgetCeiling)} ceiling (${((spent / data.health.budgetCeiling) * 100).toFixed(0)}% used).` - : ''; - return `

${esc(opts.projectName)} is ${pct}% complete across ${data.milestones.length} milestones. ${formatCost(spent)} spent.${currentExec}${budgetCtx}

`; -} - -function buildEtaLine(data: VisualizerData): string { - const act = data.agentActivity; - if (!act || act.completionRate <= 0 || data.remainingSliceCount <= 0) return ''; - const hoursRemaining = data.remainingSliceCount / act.completionRate; - const formatted = formatDuration(hoursRemaining * 3_600_000); - return `
ETA: ~${formatted} remaining (${data.remainingSliceCount} slices at ${act.completionRate.toFixed(1)}/hr)
`; -} - -// ─── Section: Blockers ──────────────────────────────────────────────────────── - -function buildBlockersSection(data: VisualizerData): string { - const blockers = data.sliceVerifications.filter(v => v.blockerDiscovered === true); - const highRisk: { msId: string; slId: string }[] = []; - for (const ms of data.milestones) { - for (const sl of ms.slices) { - if (!sl.done && sl.risk?.toLowerCase() === 'high') { - highRisk.push({ msId: ms.id, slId: sl.id }); - } - } - } - - if (blockers.length === 0 && highRisk.length === 0) { - return section('blockers', 'Blockers', '

No blockers or high-risk items found.

'); - } - - const blockerCards = blockers.map(v => ` -
-
${esc(v.milestoneId)}/${esc(v.sliceId)}
-
${esc(v.verificationResult ?? 'Blocker discovered')}
-
`).join(''); - - const riskCards = highRisk - .filter(hr => !blockers.some(b => b.milestoneId === hr.msId && b.sliceId === hr.slId)) - .map(hr => ` -
-
${esc(hr.msId)}/${esc(hr.slId)}
-
High risk — incomplete
-
`).join(''); - - return section('blockers', 'Blockers', `${blockerCards}${riskCards}`); -} - -// ─── Section: Health ────────────────────────────────────────────────────────── - -function buildHealthSection(data: VisualizerData): string { - const h = data.health; - const t = data.totals; - - const rows: string[] = []; - rows.push(hRow('Token profile', h.tokenProfile)); - if (h.budgetCeiling !== undefined) { - const spent = t?.cost ?? 0; - const pct = (spent / h.budgetCeiling) * 100; - const status = pct > 90 ? 'warn' : pct > 75 ? 'caution' : 'ok'; - rows.push(hRow( - 'Budget ceiling', - `${formatCost(h.budgetCeiling)} (${formatCost(spent)} spent, ${pct.toFixed(0)}% used)`, - status, - )); - } - rows.push(hRow( - 'Truncation rate', - `${h.truncationRate.toFixed(1)}% per unit (${t?.totalTruncationSections ?? 0} total)`, - h.truncationRate > 20 ? 'warn' : h.truncationRate > 10 ? 'caution' : 'ok', - )); - rows.push(hRow( - 'Continue-here rate', - `${h.continueHereRate.toFixed(1)}% per unit (${t?.continueHereFiredCount ?? 0} total)`, - h.continueHereRate > 15 ? 'warn' : h.continueHereRate > 8 ? 'caution' : 'ok', - )); - if (h.tierSavingsLine) rows.push(hRow('Routing savings', h.tierSavingsLine)); - rows.push(hRow('Tool calls', String(h.toolCalls))); - rows.push(hRow('Messages', `${h.assistantMessages} assistant / ${h.userMessages} user`)); - - const tierRows = h.tierBreakdown.length > 0 ? ` -

Tier breakdown

- - - - ${h.tierBreakdown.map(tb => - ` - - ` - ).join('')} - -
TierUnitsCostTokens
${esc(tb.tier)}${tb.units}${formatCost(tb.cost)}${formatTokenCount(tb.tokens.total)}
` : ''; - - // Progress score section - let progressHtml = ''; - if (h.progressScore) { - const ps = h.progressScore; - const scoreColor = ps.level === 'green' ? '#22c55e' : ps.level === 'yellow' ? '#eab308' : '#ef4444'; - const signalRows = ps.signals.map(s => { - const icon = s.kind === 'positive' ? '✓' : s.kind === 'negative' ? '✗' : '·'; - const color = s.kind === 'positive' ? '#22c55e' : s.kind === 'negative' ? '#ef4444' : '#888'; - return `
${icon} ${esc(s.label)}
`; - }).join(''); - progressHtml = ` -

Progress Score

-
● ${esc(ps.summary)}
- ${signalRows}`; - } - - // Doctor history section - let historyHtml = ''; - const doctorHistory = h.doctorHistory ?? []; - if (doctorHistory.length > 0) { - const historyRows = doctorHistory.slice(0, 20).map(entry => { - const statusIcon = entry.ok ? '✓' : '✗'; - const statusColor = entry.ok ? '#22c55e' : '#ef4444'; - const ts = entry.ts.replace('T', ' ').slice(0, 19); - const scopeTag = entry.scope ? ` [${esc(entry.scope)}]` : ''; - const summaryText = entry.summary ? esc(entry.summary) : `${entry.errors} errors, ${entry.warnings} warnings, ${entry.fixes} fixes`; - const issueDetails = (entry.issues ?? []).slice(0, 3).map(i => { - const iColor = i.severity === 'error' ? '#ef4444' : '#eab308'; - return `
${i.severity === 'error' ? '✗' : '⚠'} ${esc(i.message)} ${esc(i.unitId)}
`; - }).join(''); - const fixDetails = (entry.fixDescriptions ?? []).slice(0, 2).map(f => - `
↳ ${esc(f)}
` - ).join(''); - return ` - ${statusIcon} - ${esc(ts)}${scopeTag} - ${summaryText} - - ${issueDetails || fixDetails ? `${issueDetails}${fixDetails}` : ''}`; - }).join(''); - - historyHtml = ` -

Doctor Run History

- - - ${historyRows} -
TimeSummary
`; - } - - return section('health', 'Health', ` - ${rows.join('')}
- ${tierRows} - ${progressHtml} - ${historyHtml} - `); -} - -// ─── Section: Progress ──────────────────────────────────────────────────────── - -function buildProgressSection(data: VisualizerData): string { - if (data.milestones.length === 0) { - return section('progress', 'Progress', '

No milestones found.

'); - } - - const critMS = new Set(data.criticalPath.milestonePath); - const critSL = new Set(data.criticalPath.slicePath); - - const msHtml = data.milestones.map(ms => { - const doneCount = ms.slices.filter(s => s.done).length; - const onCrit = critMS.has(ms.id); - const sliceHtml = ms.slices.length > 0 - ? ms.slices.map(sl => buildSliceRow(sl, critSL, data)).join('') - : '

No slices in roadmap yet.

'; - - return ` -
- - - ${esc(ms.id)} - ${esc(ms.title)} - ${doneCount}/${ms.slices.length} - ${onCrit ? 'critical path' : ''} - ${ms.dependsOn.length > 0 ? `needs ${ms.dependsOn.map(esc).join(', ')}` : ''} - -
${sliceHtml}
-
`; - }).join(''); - - return section('progress', 'Progress', msHtml); -} - -function buildSliceRow(sl: VisualizerSlice, critSL: Set, data: VisualizerData): string { - const onCrit = critSL.has(sl.id); - const ver = data.sliceVerifications.find(v => v.sliceId === sl.id); - const slack = data.criticalPath.sliceSlack.get(sl.id); - const status = sl.done ? 'complete' : sl.active ? 'active' : 'pending'; - - const taskHtml = sl.tasks.length > 0 ? ` -
    - ${sl.tasks.map(t => ` -
  • - - ${esc(t.id)} - ${esc(t.title)} - ${t.estimate ? `${esc(t.estimate)}` : ''} -
  • `).join('')} -
` : ''; - - const tags = [ - ...(ver?.provides ?? []).map(p => `provides: ${esc(p)}`), - ...(ver?.requires ?? []).map(r => `requires: ${esc(r.provides)}`), - ].join(''); - - const keyDecisions = ver?.keyDecisions?.length - ? `
Decisions
    ${ver.keyDecisions.map(d => `
  • ${esc(d)}
  • `).join('')}
` - : ''; - - const patterns = ver?.patternsEstablished?.length - ? `
Patterns
    ${ver.patternsEstablished.map(p => `
  • ${esc(p)}
  • `).join('')}
` - : ''; - - const verifBadge = ver?.verificationResult - ? `
- ${ver.blockerDiscovered ? 'Blocker: ' : ''}${esc(ver.verificationResult)} -
` - : ''; - - return ` -
- - - ${esc(sl.id)} - ${esc(sl.title)} - ${esc(sl.risk || '?')} - ${sl.depends.length > 0 ? `${sl.depends.map(esc).join(', ')}` : ''} - ${onCrit ? 'critical' : ''} - ${slack !== undefined && slack > 0 ? `+${slack} slack` : ''} - -
- ${tags ? `
${tags}
` : ''} - ${verifBadge} - ${keyDecisions} - ${patterns} - ${taskHtml} -
-
`; -} - -// ─── Section: Dependency Graph ──────────────────────────────────────────────── - -function buildDepGraphSection(data: VisualizerData): string { - const hasSlices = data.milestones.some(ms => ms.slices.length > 0); - if (!hasSlices) return section('depgraph', 'Dependencies', '

No slices to graph.

'); - - const hasDeps = data.milestones.some(ms => ms.slices.some(s => s.depends.length > 0)); - if (!hasDeps) return section('depgraph', 'Dependencies', '

No dependencies defined.

'); - - const svgs = data.milestones - .filter(ms => ms.slices.length > 0) - .map(ms => buildMilestoneDepSVG(ms, data)) - .filter(Boolean) - .join(''); - - return section('depgraph', 'Dependencies', svgs); -} - -function buildMilestoneDepSVG(ms: VisualizerMilestone, data: VisualizerData): string { - const slices = ms.slices; - if (slices.length === 0) return ''; - - const critSL = new Set(data.criticalPath.slicePath); - const slMap = new Map(slices.map(s => [s.id, s])); - - const layerMap = new Map(); - const inDeg = new Map(); - for (const s of slices) inDeg.set(s.id, 0); - for (const s of slices) { - for (const dep of s.depends) { - if (slMap.has(dep)) inDeg.set(s.id, (inDeg.get(s.id) ?? 0) + 1); - } - } - - const visited = new Set(); - const q: string[] = []; - for (const [id, d] of inDeg) { - if (d === 0) { q.push(id); visited.add(id); layerMap.set(id, 0); } - } - - while (q.length > 0) { - const node = q.shift()!; - for (const s of slices) { - if (!s.depends.includes(node)) continue; - const newDeg = (inDeg.get(s.id) ?? 1) - 1; - inDeg.set(s.id, newDeg); - layerMap.set(s.id, Math.max(layerMap.get(s.id) ?? 0, (layerMap.get(node) ?? 0) + 1)); - if (newDeg === 0 && !visited.has(s.id)) { visited.add(s.id); q.push(s.id); } - } - } - for (const s of slices) if (!layerMap.has(s.id)) layerMap.set(s.id, 0); - - const maxLayer = Math.max(...[...layerMap.values()]); - const byLayer = new Map(); - for (const [id, layer] of layerMap) { - const arr = byLayer.get(layer) ?? []; - arr.push(id); - byLayer.set(layer, arr); - } - - const NW = 130, NH = 40, CGAP = 56, RGAP = 14, PAD = 20; - let maxRows = 0; - for (let c = 0; c <= maxLayer; c++) maxRows = Math.max(maxRows, (byLayer.get(c) ?? []).length); - const totalH = PAD * 2 + maxRows * NH + Math.max(0, maxRows - 1) * RGAP; - const totalW = PAD * 2 + (maxLayer + 1) * NW + maxLayer * CGAP; - - const pos = new Map(); - for (let col = 0; col <= maxLayer; col++) { - const ids = byLayer.get(col) ?? []; - const colH = ids.length * NH + Math.max(0, ids.length - 1) * RGAP; - const startY = (totalH - colH) / 2; - ids.forEach((id, i) => pos.set(id, { x: PAD + col * (NW + CGAP), y: startY + i * (NH + RGAP) })); - } - - const edges = slices.flatMap(sl => sl.depends.flatMap(dep => { - if (!pos.has(dep) || !pos.has(sl.id)) return []; - const f = pos.get(dep)!, t = pos.get(sl.id)!; - const x1 = f.x + NW, y1 = f.y + NH / 2; - const x2 = t.x, y2 = t.y + NH / 2; - const mx = (x1 + x2) / 2; - const crit = critSL.has(sl.id) && critSL.has(dep); - return [``]; - })); - - const nodes = slices.map(sl => { - const p = pos.get(sl.id); - if (!p) return ''; - const crit = critSL.has(sl.id); - const sc = sl.done ? 'n-done' : sl.active ? 'n-active' : 'n-pending'; - return ` - - ${esc(truncStr(sl.id, 18))} - ${esc(truncStr(sl.title, 18))} - ${esc(sl.id)}: ${esc(sl.title)} - `; - }); - - const legend = `
- done - active - pending - parked -
`; - - return ` -
-

${esc(ms.id)}: ${esc(ms.title)}

- ${legend} -
- - - - - - - - - - ${edges.join('')} - ${nodes.join('')} - -
-
`; -} - -// ─── Section: Metrics ───────────────────────────────────────────────────────── - -function buildMetricsSection(data: VisualizerData): string { - if (!data.totals) return section('metrics', 'Metrics', '

No metrics data yet.

'); - const t = data.totals; - - const grid = [ - kvi('Total cost', formatCost(t.cost)), - kvi('Total tokens', formatTokenCount(t.tokens.total)), - kvi('Input', formatTokenCount(t.tokens.input)), - kvi('Output', formatTokenCount(t.tokens.output)), - kvi('Cache read', formatTokenCount(t.tokens.cacheRead)), - kvi('Cache write', formatTokenCount(t.tokens.cacheWrite)), - kvi('Duration', formatDuration(t.duration)), - kvi('Units', String(t.units)), - kvi('Tool calls', String(t.toolCalls)), - kvi('Truncations', String(t.totalTruncationSections)), - ].join(''); - - const tokenBreakdown = buildTokenBreakdown(t.tokens); - - const phaseRow = data.byPhase.length > 0 ? ` -
- ${buildBarChart('Cost by phase', data.byPhase.map(p => ({ - label: p.phase, value: p.cost, display: formatCost(p.cost), sub: `${p.units} units`, - })))} - ${buildBarChart('Tokens by phase', data.byPhase.map(p => ({ - label: p.phase, value: p.tokens.total, display: formatTokenCount(p.tokens.total), sub: formatCost(p.cost), - })))} -
` : ''; - - const sliceModelRow = (data.bySlice.length > 0 || data.byModel.length > 0) ? ` -
- ${data.bySlice.length > 0 ? buildBarChart('Cost by slice', data.bySlice.map(s => ({ - label: s.sliceId, value: s.cost, display: formatCost(s.cost), - sub: `${s.units} units`, - }))) : ''} - ${data.byModel.length > 0 ? buildBarChart('Cost by model', data.byModel.map(m => ({ - label: shortModel(m.model), value: m.cost, display: formatCost(m.cost), - sub: `${m.units} units`, - }))) : ''} - ${data.bySlice.length > 0 ? buildBarChart('Duration by slice', data.bySlice.map(s => ({ - label: s.sliceId, value: s.duration, display: formatDuration(s.duration), - sub: formatCost(s.cost), - }))) : ''} -
` : ''; - - const costOverTime = buildCostOverTimeChart(data.units); - const budgetBurndown = buildBudgetBurndown(data); - const gantt = buildSliceGantt(data); - - return section('metrics', 'Metrics', ` -
${grid}
- ${budgetBurndown} - ${tokenBreakdown} - ${costOverTime} - ${phaseRow} - ${sliceModelRow} - ${gantt} - `); -} - -function buildCostOverTimeChart(units: UnitMetrics[]): string { - if (units.length < 2) return ''; - const sorted = [...units].sort((a, b) => a.startedAt - b.startedAt); - const cumulative: number[] = []; - let running = 0; - for (const u of sorted) { - running += u.cost; - cumulative.push(running); - } - - const padL = 50, padR = 30, padT = 20, padB = 30; - const w = 600, h = 200; - const plotW = w - padL - padR; - const plotH = h - padT - padB; - const maxCost = cumulative[cumulative.length - 1] || 1; - const n = cumulative.length; - - const points = cumulative.map((c, i) => { - const x = padL + (i / (n - 1)) * plotW; - const y = padT + plotH - (c / maxCost) * plotH; - return { x, y }; - }); - - const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); - const areaPath = `${linePath} L${points[points.length - 1].x.toFixed(1)},${(padT + plotH).toFixed(1)} L${points[0].x.toFixed(1)},${(padT + plotH).toFixed(1)} Z`; - - const gridLines: string[] = []; - for (let i = 0; i <= 4; i++) { - const y = padT + (plotH / 4) * i; - const val = formatCost(maxCost * (1 - i / 4)); - gridLines.push(``); - gridLines.push(`${val}`); - } - - return ` -
-

Cost over time

- - ${gridLines.join('')} - - - #1 - #${n} - -
`; -} - -function buildBudgetBurndown(data: VisualizerData): string { - if (!data.health.budgetCeiling) return ''; - const ceiling = data.health.budgetCeiling; - const spent = data.totals?.cost ?? 0; - const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); - const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); - const avgCostPerSlice = doneSlices > 0 ? spent / doneSlices : 0; - const projected = avgCostPerSlice > 0 ? avgCostPerSlice * data.remainingSliceCount + spent : spent; - const maxVal = Math.max(ceiling, projected, spent); - - const spentPct = (spent / maxVal) * 100; - const projectedRemPct = Math.max(0, ((projected - spent) / maxVal) * 100); - const overshoot = projected > ceiling ? ((projected - ceiling) / maxVal) * 100 : 0; - const projectedClean = projectedRemPct - overshoot; - - const legend = [ - ` Spent: ${formatCost(spent)}`, - ` Projected remaining: ${formatCost(Math.max(0, projected - spent))}`, - ` Ceiling: ${formatCost(ceiling)}`, - overshoot > 0 ? ` Overshoot: ${formatCost(projected - ceiling)}` : '', - ].filter(Boolean).join(''); - - return ` -
-

Budget burndown

-
-
- ${projectedClean > 0 ? `
` : ''} - ${overshoot > 0 ? `
` : ''} -
-
${legend}
-
`; -} - -function buildSliceGantt(data: VisualizerData): string { - const sliceTimings = new Map(); - for (const u of data.units) { - const parts = u.id.split('/'); - const sliceKey = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : u.id; - if (u.startedAt <= 0) continue; - const existing = sliceTimings.get(sliceKey); - const end = u.finishedAt > 0 ? u.finishedAt : Date.now(); - if (existing) { - existing.min = Math.min(existing.min, u.startedAt); - existing.max = Math.max(existing.max, end); - } else { - sliceTimings.set(sliceKey, { min: u.startedAt, max: end }); - } - } - - if (sliceTimings.size < 2) return ''; - - const sliceEntries = [...sliceTimings.entries()].sort((a, b) => a[1].min - b[1].min); - const globalMin = Math.min(...sliceEntries.map(e => e[1].min)); - const globalMax = Math.max(...sliceEntries.map(e => e[1].max)); - const range = globalMax - globalMin || 1; - - const sliceCount = sliceEntries.length; - const barH = 18, rowH = 30, padL = 140, padR = 20, padT = 30, padB = 30; - const plotW = 700 - padL - padR; - const svgH = sliceCount * rowH + padT + padB; - - // Build a lookup of slice status - const sliceStatusMap = new Map(); - for (const ms of data.milestones) { - for (const sl of ms.slices) { - const key = `${ms.id}/${sl.id}`; - sliceStatusMap.set(key, sl.done ? 'done' : sl.active ? 'active' : 'pending'); - } - } - - const bars = sliceEntries.map(([sliceId, timing], i) => { - const x = padL + ((timing.min - globalMin) / range) * plotW; - const w = Math.max(2, ((timing.max - timing.min) / range) * plotW); - const y = padT + i * rowH + (rowH - barH) / 2; - const status = sliceStatusMap.get(sliceId) ?? 'pending'; - return `${esc(truncStr(sliceId, 18))} - ${esc(sliceId)}: ${formatDuration(timing.max - timing.min)}`; - }).join('\n'); - - // Time axis labels - const axisLabels = [0, 0.25, 0.5, 0.75, 1].map(frac => { - const t = globalMin + frac * range; - const x = padL + frac * plotW; - return `${formatDateShort(new Date(t).toISOString())}`; - }).join(''); - - return ` -
-

Slice timeline

- - ${bars} - ${axisLabels} - -
`; -} - -function buildTokenBreakdown(tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }): string { - if (tokens.total === 0) return ''; - const segs = [ - { label: 'Input', value: tokens.input, cls: 'seg-1' }, - { label: 'Output', value: tokens.output, cls: 'seg-2' }, - { label: 'Cache read', value: tokens.cacheRead, cls: 'seg-3' }, - { label: 'Cache write', value: tokens.cacheWrite, cls: 'seg-4' }, - ].filter(s => s.value > 0); - - const bars = segs.map(s => { - const pct = (s.value / tokens.total) * 100; - return `
`; - }).join(''); - - const legend = segs.map(s => { - const pct = ((s.value / tokens.total) * 100).toFixed(1); - return `${s.label}: ${formatTokenCount(s.value)} (${pct}%)`; - }).join(''); - - return ` -
-

Token breakdown

-
${bars}
-
${legend}
-
`; -} - -interface BarEntry { label: string; value: number; display: string; sub?: string; color?: number } - -const CHART_COLORS = 6; - -function buildBarChart(title: string, entries: BarEntry[]): string { - if (entries.length === 0) return ''; - const max = Math.max(...entries.map(e => e.value), 1); - const rows = entries.map((e, i) => { - const pct = (e.value / max) * 100; - const ci = e.color ?? i; - return ` -
-
${esc(truncStr(e.label, 22))}
-
-
${esc(e.display)}
-
- ${e.sub ? `
${esc(e.sub)}
` : ''}`; - }).join(''); - return `

${esc(title)}

${rows}
`; -} - -// ─── Section: Timeline ──────────────────────────────────────────────────────── - -function buildTimelineSection(data: VisualizerData): string { - if (data.units.length === 0) return section('timeline', 'Timeline', '

No units executed yet.

'); - - const sorted = [...data.units].sort((a, b) => a.startedAt - b.startedAt); - const maxCost = Math.max(...sorted.map(u => u.cost), 0.01); - - const rows = sorted.map((u, i) => { - const dur = u.finishedAt > 0 ? formatDuration(u.finishedAt - u.startedAt) : 'running'; - // Cost heatmap: subtle red background for expensive rows - const intensity = Math.min(u.cost / maxCost, 1); - const heatStyle = intensity > 0.15 ? ` style="background:rgba(239,68,68,${(intensity * 0.15).toFixed(3)})"` : ''; - return ` - - ${i + 1} - ${esc(u.type)} - ${esc(u.id)} - ${esc(shortModel(u.model))} - ${formatDateShort(new Date(u.startedAt).toISOString())} - ${dur} - ${formatCost(u.cost)} - ${formatTokenCount(u.tokens.total)} - ${u.toolCalls} - ${u.tier ?? ''} - ${u.modelDowngraded ? 'routed' : ''} - ${(u.truncationSections ?? 0) > 0 ? u.truncationSections : ''} - ${u.continueHereFired ? 'yes' : ''} - `; - }).join(''); - - return section('timeline', 'Timeline', ` -
- - - - - - - ${rows} -
#TypeIDModelStartedDurationCostTokensToolsTierRoutedTruncCHF
-
`); -} - -// ─── Section: Changelog ─────────────────────────────────────────────────────── - -function buildChangelogSection(data: VisualizerData): string { - if (data.changelog.entries.length === 0) return section('changelog', 'Changelog', '

No completed slices yet.

'); - - const entries = data.changelog.entries.map(e => { - const filesHtml = e.filesModified.length > 0 ? ` -
- ${e.filesModified.length} file${e.filesModified.length !== 1 ? 's' : ''} modified -
    - ${e.filesModified.map(f => `
  • ${esc(f.path)}${f.description ? ` — ${esc(f.description)}` : ''}
  • `).join('')} -
-
` : ''; - - const ver = data.sliceVerifications.find(v => v.sliceId === e.sliceId); - const decisionsHtml = ver?.keyDecisions?.length ? ` -
Decisions -
    ${ver.keyDecisions.map(d => `
  • ${esc(d)}
  • `).join('')}
-
` : ''; - - return ` -
-
- ${esc(e.milestoneId)}/${esc(e.sliceId)} - ${esc(e.title)} - ${e.completedAt ? `${formatDateShort(e.completedAt)}` : ''} -
- ${e.oneLiner ? `

${esc(e.oneLiner)}

` : ''} - ${decisionsHtml} - ${filesHtml} -
`; - }).join(''); - - return section('changelog', `Changelog ${data.changelog.entries.length}`, entries); -} - -// ─── Section: Knowledge ─────────────────────────────────────────────────────── - -function buildKnowledgeSection(data: VisualizerData): string { - const k = data.knowledge; - if (!k.exists) return section('knowledge', 'Knowledge', '

No KNOWLEDGE.md found.

'); - const total = k.rules.length + k.patterns.length + k.lessons.length; - if (total === 0) return section('knowledge', 'Knowledge', '

KNOWLEDGE.md exists but no entries parsed.

'); - - const rulesHtml = k.rules.length > 0 ? ` -

Rules ${k.rules.length}

- - - ${k.rules.map(r => ``).join('')} -
IDScopeRule
${esc(r.id)}${esc(r.scope)}${esc(r.content)}
` : ''; - - const patternsHtml = k.patterns.length > 0 ? ` -

Patterns ${k.patterns.length}

- - - ${k.patterns.map(p => ``).join('')} -
IDPattern
${esc(p.id)}${esc(p.content)}
` : ''; - - const lessonsHtml = k.lessons.length > 0 ? ` -

Lessons ${k.lessons.length}

- - - ${k.lessons.map(l => ``).join('')} -
IDLesson
${esc(l.id)}${esc(l.content)}
` : ''; - - return section('knowledge', `Knowledge ${total}`, `${rulesHtml}${patternsHtml}${lessonsHtml}`); -} - -// ─── Section: Captures ──────────────────────────────────────────────────────── - -function buildCapturesSection(data: VisualizerData): string { - const c = data.captures; - if (c.totalCount === 0) return section('captures', 'Captures', '

No captures recorded.

'); - - const badge = c.pendingCount > 0 - ? `${c.pendingCount} pending` - : `all triaged`; - - const rows = c.entries.map(e => ` - - ${formatDateShort(new Date(e.timestamp).toISOString())} - ${esc(e.status)} - ${e.classification ?? ''} - ${e.resolution ?? ''} - ${esc(e.text)} - ${e.rationale ?? ''} - ${e.resolvedAt ? formatDateShort(e.resolvedAt) : ''} - ${e.executed !== undefined ? (e.executed ? 'yes' : 'no') : ''} - `).join(''); - - return section('captures', `Captures ${badge}`, ` -
- - - ${rows} -
CapturedStatusClassResolutionTextRationaleResolvedExecuted
-
`); -} - -// ─── Section: Stats ─────────────────────────────────────────────────────────── - -function buildStatsSection(data: VisualizerData): string { - const s = data.stats; - - const missingHtml = s.missingCount > 0 ? ` -

Missing changelogs ${s.missingCount}

- - - - ${s.missingSlices.map(sl => ``).join('')} - ${s.missingCount > s.missingSlices.length - ? `` - : ''} - -
MilestoneSliceTitle
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}
and ${s.missingCount - s.missingSlices.length} more
` : ''; - - const updatedHtml = s.updatedCount > 0 ? ` -

Recently completed ${s.updatedCount}

- - - ${s.updatedSlices.map(sl => ` - `).join('')} - -
MilestoneSliceTitleCompleted
${esc(sl.milestoneId)}${esc(sl.sliceId)}${esc(sl.title)}${sl.completedAt ? formatDateShort(sl.completedAt) : ''}
` : ''; - - if (!missingHtml && !updatedHtml) { - return section('stats', 'Artifacts', '

All artifacts accounted for.

'); - } - - return section('stats', 'Artifacts', `${missingHtml}${updatedHtml}`); -} - -// ─── Section: Discussion ────────────────────────────────────────────────────── - -function buildDiscussionSection(data: VisualizerData): string { - if (data.discussion.length === 0) return section('discussion', 'Planning', '

No milestones.

'); - - const rows = data.discussion.map(d => ` - - ${esc(d.milestoneId)} - ${esc(d.title)} - ${d.state} - ${d.hasContext ? 'yes' : ''} - ${d.hasDraft ? 'draft' : ''} - ${d.lastUpdated ? formatDateShort(d.lastUpdated) : ''} - `).join(''); - - return section('discussion', 'Planning', ` - - - ${rows} -
IDMilestoneStateContextDraftUpdated
`); -} - -// ─── Primitives ──────────────────────────────────────────────────────────────── - -function section(id: string, title: string, body: string): string { - return `\n
\n

${title}

\n ${body}\n
`; -} - -function kvi(label: string, value: string): string { - return `
${esc(value)}${esc(label)}
`; -} - -function hRow(label: string, value: string, status?: 'ok' | 'caution' | 'warn'): string { - const cls = status ? ` class="h-${status}"` : ''; - return `${esc(label)}${esc(value)}`; -} - -function shortModel(m: string) { return m.replace(/^claude-/, '').replace(/^anthropic\//, ''); } -function truncStr(s: string, n: number) { return s.length > n ? s.slice(0, n - 1) + '\u2026' : s; } - -function formatDateLong(iso: string): string { - try { - const d = new Date(iso); - return d.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); - } catch { return iso; } -} - - -function esc(s: string | undefined | null): string { - if (s == null) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); -} - -// ─── CSS ─────────────────────────────────────────────────────────────────────── -// Linear-inspired: restrained palette, one accent, no emoji, no gradients. - -const CSS = ` -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} -:root{ - --bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33; - --border-1:#2b2e38;--border-2:#3b3f4c; - --text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a; - --accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12); - --ok:#22c55e;--ok-subtle:rgba(34,197,94,.12);--warn:#ef4444;--caution:#eab308; - /* Chart palette — 6 hues for bar charts */ - --c0:#5e6ad2;--c1:#e5796d;--c2:#14b8a6;--c3:#a78bfa;--c4:#f59e0b;--c5:#10b981; - /* Token breakdown — 4 distinct hues */ - --tk-input:#5e6ad2;--tk-output:#e5796d;--tk-cache-r:#2dd4bf;--tk-cache-w:#64748b; - --font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; - --mono:'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,monospace; -} -html{scroll-behavior:smooth;font-size:13px} -body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} -a{color:var(--accent);text-decoration:none} -a:hover{text-decoration:underline} -code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px} -.mono{font-family:var(--mono);font-size:12px} -.muted{color:var(--text-2)} -.accent{color:var(--accent)} -.sep{color:var(--border-2);margin:0 4px} -.empty{color:var(--text-2);padding:8px 0;font-size:13px} -.indent{padding-left:12px} -.num{font-variant-numeric:tabular-nums;text-align:right} - -/* Status dots — geometric, no emoji */ -.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;vertical-align:middle} -.dot-sm{width:6px;height:6px} -.dot-complete{background:var(--ok);opacity:.6} -.dot-active{background:var(--accent)} -.dot-pending{background:transparent;border:1.5px solid var(--border-2)} -.dot-parked{background:var(--warn);opacity:.5} - -/* Header */ -header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:200} -.header-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto} -.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0} -.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)} -.version{font-size:10px;color:var(--text-2);font-family:var(--mono)} -.header-meta{flex:1;min-width:0} -.header-meta h1{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.header-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.header-right{text-align:right;flex-shrink:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px} -.generated{font-size:11px;color:var(--text-2)} -.back-link{font-size:12px;color:var(--text-1)} -.back-link:hover{color:var(--accent)} - -/* TOC nav */ -.toc{background:var(--bg-1);border-bottom:1px solid var(--border-1);overflow-x:auto} -.toc ul{display:flex;list-style:none;max-width:1280px;margin:0 auto;padding:0 32px} -.toc a{display:inline-block;padding:8px 12px;color:var(--text-2);font-size:12px;font-weight:500;border-bottom:2px solid transparent;transition:color .12s,border-color .12s;white-space:nowrap;text-decoration:none} -.toc a:hover{color:var(--text-0);border-bottom-color:var(--border-2)} -.toc a.active{color:var(--text-0);border-bottom-color:var(--accent)} - -/* Layout */ -main{max-width:1280px;margin:0 auto;padding:32px;display:flex;flex-direction:column;gap:48px} -section{scroll-margin-top:82px} -section>h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1);display:flex;align-items:center;gap:8px} -h3{font-size:13px;font-weight:600;color:var(--text-1);margin:20px 0 8px} -.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} -.count-warn{color:var(--caution)} - -/* KV grid (stats/metrics) */ -.kv-grid{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px} -.kv{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:110px;flex:1} -.kv-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums} -.kv-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px} - -/* Progress bar */ -.progress-wrap{display:flex;align-items:center;gap:10px;margin-bottom:12px} -.progress-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden} -.progress-fill{height:100%;background:var(--accent);border-radius:2px} -.progress-label{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right} -.active-info{font-size:12px;color:var(--text-1);margin-bottom:4px} -.activity-line{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-1);padding:6px 0} - -/* Tables */ -.tbl{width:100%;border-collapse:collapse;font-size:12px} -.tbl th{color:var(--text-2);font-weight:500;padding:6px 12px;text-align:left;border-bottom:1px solid var(--border-1);font-size:11px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap} -.tbl td{padding:6px 12px;border-bottom:1px solid var(--border-1);vertical-align:top} -.tbl tr:last-child td{border-bottom:none} -.tbl tbody tr:hover td{background:var(--accent-subtle)} -.tbl-kv td:first-child{color:var(--text-2);width:180px} -.table-scroll{overflow-x:auto;border:1px solid var(--border-1);border-radius:4px} -.table-scroll .tbl{border:none} - -/* Health */ -.h-ok td:first-child{color:var(--text-1)} -.h-caution td{color:var(--caution)} -.h-warn td{color:var(--warn)} - -/* Labels */ -.label{font-size:10px;font-weight:500;color:var(--accent);text-transform:uppercase;letter-spacing:.4px} -.risk{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.3px;flex-shrink:0} -.risk-low{color:var(--text-2)} -.risk-medium{color:var(--caution)} -.risk-high{color:var(--warn)} -.risk-unknown{color:var(--text-2)} - -/* Tags */ -.tag-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px} -.tag{font-size:11px;font-family:var(--mono);color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px} - -/* Verification */ -.verif{font-size:12px;color:var(--text-1);padding:4px 0;margin-bottom:6px} -.verif-blocker{color:var(--warn)} - -/* Detail blocks */ -.detail-block{font-size:12px;color:var(--text-2);margin-bottom:6px} -.detail-label{font-weight:600;color:var(--text-1);display:block;margin-bottom:2px} -.detail-block ul{padding-left:16px;margin-top:2px} -.detail-block li{margin-bottom:1px} - -/* Progress tree */ -.ms-block{border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:8px} -.ms-summary{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;list-style:none;background:var(--bg-1);user-select:none;font-size:13px} -.ms-summary:hover{background:var(--bg-2)} -.ms-summary::-webkit-details-marker{display:none} -.ms-id{font-weight:600} -.ms-title{flex:1;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.ms-body{padding:6px 12px 8px 24px;display:flex;flex-direction:column;gap:4px} - -.sl-block{border:1px solid var(--border-1);border-radius:3px;overflow:hidden} -.sl-summary{display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;list-style:none;background:var(--bg-2);font-size:12px;user-select:none} -.sl-summary:hover{background:var(--bg-3)} -.sl-summary::-webkit-details-marker{display:none} -.sl-crit{border-left:2px solid var(--accent)} -.sl-deps::before{content:'\\2190 ';color:var(--border-2)} -.sl-detail{padding:8px 12px;background:var(--bg-0);border-top:1px solid var(--border-1)} - -.task-list{list-style:none;padding:4px 0 0;display:flex;flex-direction:column;gap:2px} -.task-row{display:flex;align-items:center;gap:6px;font-size:12px;padding:3px 6px;border-radius:2px} - -/* Dep graph */ -.dep-block{margin-bottom:28px} -.dep-legend{display:flex;gap:14px;font-size:12px;color:var(--text-2);margin-bottom:8px;align-items:center} -.dep-legend span{display:flex;align-items:center;gap:4px} -.dep-wrap{overflow-x:auto;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:16px} -.dep-svg{display:block} -.edge{fill:none;stroke:var(--border-2);stroke-width:1.5} -.edge-crit{stroke:var(--accent);stroke-width:2} -.node rect{fill:var(--bg-2);stroke:var(--border-2);stroke-width:1} -.n-done rect{fill:var(--ok-subtle);stroke:rgba(34,197,94,.4)} -.n-active rect{fill:var(--accent-subtle);stroke:var(--accent)} -.n-crit rect{stroke:var(--accent)!important;stroke-width:1.5!important} -.n-id{font-family:var(--mono);font-size:10px;fill:var(--text-1);font-weight:600;text-anchor:middle} -.n-title{font-size:9px;fill:var(--text-2);text-anchor:middle} -.n-active .n-id{fill:var(--accent)} - -/* Metrics */ -.token-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px;margin-bottom:16px} -.token-bar{display:flex;height:16px;border-radius:2px;overflow:hidden;gap:1px;margin-bottom:8px} -.tseg{height:100%;min-width:2px} -.seg-1{background:var(--tk-input)} -.seg-2{background:var(--tk-output)} -.seg-3{background:var(--tk-cache-r)} -.seg-4{background:var(--tk-cache-w)} -.token-legend{display:flex;flex-wrap:wrap;gap:12px} -.leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-2)} -.leg-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0} -.chart-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:16px} -.chart-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px} -.bar-row{display:grid;grid-template-columns:120px 1fr 68px;align-items:center;gap:6px;margin-bottom:2px} -.bar-lbl{font-size:12px;color:var(--text-2);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bar-track{height:14px;background:var(--bg-3);border-radius:2px;overflow:hidden} -.bar-fill{height:100%;border-radius:2px;background:var(--c0)} -.bar-c0{background:var(--c0)}.bar-c1{background:var(--c1)}.bar-c2{background:var(--c2)} -.bar-c3{background:var(--c3)}.bar-c4{background:var(--c4)}.bar-c5{background:var(--c5)} -.bar-val{font-size:11px;font-variant-numeric:tabular-nums;color:var(--text-1)} -.bar-sub{font-size:10px;color:var(--text-2);padding-left:128px;margin-bottom:6px} - -/* Changelog */ -.cl-entry{border-bottom:1px solid var(--border-1);padding:12px 0} -.cl-entry:last-child{border-bottom:none} -.cl-header{display:flex;align-items:center;gap:8px;margin-bottom:4px} -.cl-title{flex:1;font-weight:500} -.cl-date{margin-left:auto;white-space:nowrap} -.cl-liner{font-size:13px;color:var(--text-1);margin-bottom:6px} -.files-detail summary{font-size:12px;cursor:pointer} -.file-list{list-style:none;padding-left:10px;margin-top:4px;display:flex;flex-direction:column;gap:2px} -.file-list li{font-size:12px;color:var(--text-1)} - -/* Footer */ -footer{border-top:1px solid var(--border-1);padding:20px 32px;margin-top:40px} -.footer-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)} - -/* Executive summary & ETA */ -.exec-summary{font-size:13px;color:var(--text-1);margin-bottom:12px;line-height:1.7} -.eta-line{font-size:12px;color:var(--accent);margin-top:4px} - -/* Cost over time chart */ -.cost-svg{display:block;margin:8px 0;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px} -.cost-line{fill:none;stroke:var(--accent);stroke-width:2} -.cost-area{fill:var(--accent-subtle);stroke:none} -.cost-axis{fill:var(--text-2);font-family:var(--mono);font-size:10px} -.cost-grid{stroke:var(--border-1);stroke-width:1;stroke-dasharray:4,4} - -/* Budget burndown */ -.burndown-wrap{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px;margin-bottom:16px} -.burndown-bar{display:flex;height:20px;border-radius:3px;overflow:hidden;gap:1px;margin-bottom:8px} -.burndown-spent{background:var(--accent);height:100%} -.burndown-projected{background:var(--caution);height:100%;opacity:.6} -.burndown-overshoot{background:var(--warn);height:100%;opacity:.7} -.burndown-legend{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:var(--text-2)} -.burndown-legend span{display:flex;align-items:center;gap:4px} -.burndown-dot{display:inline-block;width:8px;height:8px;border-radius:2px} - -/* Blockers */ -.blocker-card{border-left:3px solid var(--warn);background:var(--bg-1);border-radius:0 4px 4px 0;padding:10px 14px;margin-bottom:8px} -.blocker-id{font-family:var(--mono);font-size:12px;color:var(--warn);margin-bottom:2px} -.blocker-text{font-size:12px;color:var(--text-1)} -.blocker-risk{font-size:11px;color:var(--caution);margin-top:2px} - -/* Gantt */ -.gantt-wrap{overflow-x:auto;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:16px;margin-top:16px} -.gantt-svg{display:block} -.gantt-bar-done{fill:var(--ok);opacity:.7} -.gantt-bar-active{fill:var(--accent)} -.gantt-bar-pending{fill:var(--border-2)} -.gantt-label{fill:var(--text-2);font-family:var(--mono);font-size:10px} -.gantt-axis{fill:var(--text-2);font-family:var(--mono);font-size:9px} - -/* Interactive */ -.tl-filter{display:block;width:100%;padding:6px 10px;margin-bottom:8px;background:var(--bg-2);border:1px solid var(--border-1);border-radius:4px;color:var(--text-0);font-size:12px;font-family:var(--font);outline:none} -.tl-filter:focus{border-color:var(--accent)} -.tl-filter::placeholder{color:var(--text-2)} -.sec-toggle{background:none;border:1px solid var(--border-2);color:var(--text-2);width:20px;height:20px;border-radius:3px;cursor:pointer;font-size:14px;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0} -.sec-toggle:hover{border-color:var(--text-1);color:var(--text-1)} -.theme-toggle{background:var(--bg-3);border:1px solid var(--border-2);color:var(--text-1);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:var(--font)} -.theme-toggle:hover{border-color:var(--accent);color:var(--accent)} - -/* Light theme */ -.light-theme{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5;--accent-subtle:rgba(79,70,229,.08);--ok:#16a34a;--ok-subtle:rgba(22,163,74,.08);--warn:#dc2626;--caution:#ca8a04;--c0:#4f46e5;--c1:#dc2626;--c2:#0d9488;--c3:#7c3aed;--c4:#d97706;--c5:#059669;--tk-input:#4f46e5;--tk-output:#dc2626;--tk-cache-r:#0d9488;--tk-cache-w:#64748b} - -/* Responsive */ -@media(max-width:768px){ - header{padding:10px 16px} - .header-inner{flex-wrap:wrap;gap:8px} - .header-meta h1{font-size:13px} - main{padding:16px} - .kv-grid{gap:1px} - .kv{min-width:80px;padding:8px 10px} - .kv-val{font-size:14px} - .chart-row{grid-template-columns:1fr} - .toc ul{padding:0 16px} - .toc a{padding:6px 8px;font-size:11px} - .bar-row{grid-template-columns:80px 1fr 56px} - .ms-body{padding-left:12px} -} -@media(max-width:480px){ - .kv{min-width:60px;padding:6px 8px} - .kv-val{font-size:12px} - .kv-lbl{font-size:9px} - .bar-row{grid-template-columns:60px 1fr 48px} - .bar-lbl{font-size:10px} - .toc ul{flex-wrap:wrap} - .header-right{display:none} - .gantt-wrap{overflow-x:auto} -} - -/* Print */ -@media print{ - header,nav.toc{position:static} - body{background:#fff;color:#1a1a1a} - :root{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5;--ok:#16a34a;--ok-subtle:rgba(22,163,74,.08);--c0:#4f46e5;--c1:#dc2626;--c2:#0d9488;--c3:#7c3aed;--c4:#d97706;--c5:#059669;--tk-input:#4f46e5;--tk-output:#dc2626;--tk-cache-r:#0d9488;--tk-cache-w:#64748b} - section{page-break-inside:avoid} - .table-scroll{overflow:visible} -} -`; - -// ─── JS ──────────────────────────────────────────────────────────────────────── - -const JS = ` -(function(){ - const sections=document.querySelectorAll('section[id]'); - const links=document.querySelectorAll('.toc a'); - if(!sections.length||!links.length)return; - const obs=new IntersectionObserver(entries=>{ - for(const e of entries){ - if(!e.isIntersecting)continue; - for(const l of links)l.classList.remove('active'); - const a=document.querySelector('.toc a[href="#'+e.target.id+'"]'); - if(a)a.classList.add('active'); - } - },{rootMargin:'-10% 0px -80% 0px',threshold:0}); - for(const s of sections)obs.observe(s); -})(); -(function(){ - var tl=document.getElementById('timeline'); - if(!tl)return; - var table=tl.querySelector('.tbl'); - if(!table)return; - var input=document.createElement('input'); - input.className='tl-filter'; - input.placeholder='Filter timeline\\u2026'; - input.type='text'; - table.parentNode.insertBefore(input,table); - var rows=table.querySelectorAll('tbody tr'); - input.addEventListener('input',function(){ - var q=this.value.toLowerCase(); - for(var i=0;i-1?'':'none'; - } - }); -})(); -(function(){ - var saved=JSON.parse(localStorage.getItem('gsd-collapsed')||'{}'); - document.querySelectorAll('section[id]').forEach(function(sec){ - var h2=sec.querySelector('h2'); - if(!h2)return; - var btn=document.createElement('button'); - btn.className='sec-toggle'; - btn.textContent=saved[sec.id]?'+':'-'; - btn.setAttribute('aria-label','Toggle section'); - h2.prepend(btn); - if(saved[sec.id])toggleSection(sec,true); - btn.addEventListener('click',function(e){ - e.preventDefault(); - var collapsed=btn.textContent==='-'; - toggleSection(sec,collapsed); - btn.textContent=collapsed?'+':'-'; - saved[sec.id]=collapsed; - localStorage.setItem('gsd-collapsed',JSON.stringify(saved)); - }); - }); - function toggleSection(sec,hide){ - var children=sec.children; - for(var i=0;i {}); - } else { - const cmd = process.platform === "darwin" ? "open" : "xdg-open"; - execFile(cmd, [filePath], () => {}); - } -} - -/** - * Write an export file directly, without requiring an ExtensionCommandContext. - * Used by the visualizer overlay export tab. - * Returns the output file path, or null on failure. - */ -export function writeExportFile( - basePath: string, - format: "markdown" | "json", - visualizerData?: { totals: any; byPhase: any[]; bySlice: any[]; byModel: any[]; units: any[]; criticalPath?: any; remainingSliceCount?: number }, -): string | null { - const ledger = getLedger(); - let units: UnitMetrics[]; - - if (visualizerData && visualizerData.units.length > 0) { - units = visualizerData.units; - } else if (ledger && ledger.units.length > 0) { - units = ledger.units; - } else { - const diskLedger = loadLedgerFromDisk(basePath); - if (!diskLedger || diskLedger.units.length === 0) return null; - units = diskLedger.units; - } - - const projectName = basename(basePath); - const exportDir = gsdRoot(basePath); - mkdirSync(exportDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - - if (format === "json") { - const report = { - exportedAt: new Date().toISOString(), - project: projectName, - totals: visualizerData?.totals ?? getProjectTotals(units), - byPhase: visualizerData?.byPhase ?? aggregateByPhase(units), - bySlice: visualizerData?.bySlice ?? aggregateBySlice(units), - byModel: visualizerData?.byModel ?? aggregateByModel(units), - units, - }; - const outPath = join(exportDir, `export-${timestamp}.json`); - writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8"); - return outPath; - } else { - const totals = visualizerData?.totals ?? getProjectTotals(units); - const phases = visualizerData?.byPhase ?? aggregateByPhase(units); - const slices = visualizerData?.bySlice ?? aggregateBySlice(units); - - const md = [ - `# SF Session Report — ${projectName}`, - ``, - `**Generated**: ${new Date().toISOString()}`, - `**Units completed**: ${totals.units}`, - `**Total cost**: ${formatCost(totals.cost)}`, - `**Total tokens**: ${formatTokenCount(totals.tokens.total)}`, - `**Total duration**: ${formatDuration(totals.duration)}`, - `**Tool calls**: ${totals.toolCalls}`, - ``, - `## Cost by Phase`, - ``, - `| Phase | Units | Cost | Tokens | Duration |`, - `|-------|-------|------|--------|----------|`, - ...phases.map((p: any) => - `| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`, - ), - ``, - `## Cost by Slice`, - ``, - `| Slice | Units | Cost | Tokens | Duration |`, - `|-------|-------|------|--------|----------|`, - ...slices.map((s: any) => - `| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`, - ), - ``, - ].join("\n"); - - const outPath = join(exportDir, `export-${timestamp}.md`); - writeFileSync(outPath, md, "utf-8"); - return outPath; - } -} - -/** - * Export session/milestone data to JSON, markdown, or HTML. - */ -export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise { - // HTML report — delegates to the full visualizer-data pipeline - if (args.includes("--html")) { - const generateAll = args.includes("--all"); - try { - const { loadVisualizerData } = await import("./visualizer-data.js"); - const { generateHtmlReport } = await import("./export-html.js"); - const { writeReportSnapshot, loadReportsIndex } = await import("./reports.js"); - const { basename: bn } = await import("node:path"); - const data = await loadVisualizerData(basePath); - const projName = basename(basePath); - const gsdVersion = process.env.SF_VERSION ?? "0.0.0"; - const doneMilestones = data.milestones.filter(m => m.status === "complete").length; - - const htmlOpts = { - projectName: projName, - projectPath: basePath, - gsdVersion, - indexRelPath: "index.html", - }; - - if (generateAll) { - // Generate a report snapshot for every milestone - const existing = loadReportsIndex(basePath); - const existingIds = new Set(existing?.entries.map(e => e.milestoneId) ?? []); - - const targets = data.milestones.filter(m => !existingIds.has(m.id)); - if (targets.length === 0) { - ctx.ui.notify( - "All milestones already have report snapshots. Run without --all to create a new snapshot for the active milestone.", - "info", - ); - return; - } - - const html = generateHtmlReport(data, htmlOpts); - const paths: string[] = []; - - for (const ms of targets) { - const msSlicesDone = ms.slices.filter(sl => sl.done).length; - const msSlicesTotal = ms.slices.length; - - // Accumulate project-wide progress up to and including this milestone - const msIdx = data.milestones.indexOf(ms); - let cumulativeDone = 0; - let cumulativeTotal = 0; - for (let i = 0; i <= msIdx; i++) { - cumulativeDone += data.milestones[i].slices.filter(sl => sl.done).length; - cumulativeTotal += data.milestones[i].slices.length; - } - - const outPath = writeReportSnapshot({ - basePath, - html, - milestoneId: ms.id, - milestoneTitle: ms.title, - kind: ms.status === "complete" ? "milestone" : "manual", - projectName: projName, - projectPath: basePath, - gsdVersion, - totalCost: data.totals?.cost ?? 0, - totalTokens: data.totals?.tokens.total ?? 0, - totalDuration: data.totals?.duration ?? 0, - doneSlices: cumulativeDone, - totalSlices: cumulativeTotal, - doneMilestones: data.milestones.slice(0, msIdx + 1).filter(m => m.status === "complete").length, - totalMilestones: data.milestones.length, - phase: ms.status === "complete" ? "complete" : data.phase, - }); - paths.push(bn(outPath)); - } - - const indexPath = join(gsdRoot(basePath), "reports", "index.html"); - ctx.ui.notify( - `Generated ${paths.length} report snapshot${paths.length !== 1 ? "s" : ""}:\n${paths.map(p => ` ${p}`).join("\n")}\nOpening reports index in browser...`, - "success", - ); - openInBrowser(indexPath); - } else { - // Single report for the active milestone (existing behavior) - const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0); - const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0); - const outPath = writeReportSnapshot({ - basePath, - html: generateHtmlReport(data, htmlOpts), - milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual", - milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "", - kind: "manual", - projectName: projName, - projectPath: basePath, - gsdVersion, - totalCost: data.totals?.cost ?? 0, - totalTokens: data.totals?.tokens.total ?? 0, - totalDuration: data.totals?.duration ?? 0, - doneSlices, - totalSlices, - doneMilestones, - totalMilestones: data.milestones.length, - phase: data.phase, - }); - ctx.ui.notify( - `HTML report saved: .gsd/reports/${bn(outPath)}\nOpening in browser...`, - "success", - ); - openInBrowser(outPath); - } - } catch (err) { - ctx.ui.notify( - `HTML export failed: ${getErrorMessage(err)}`, - "error", - ); - } - return; - } - - const format = args.includes("--json") ? "json" : "markdown"; - - const ledger = getLedger(); - let units: UnitMetrics[]; - - if (ledger && ledger.units.length > 0) { - units = ledger.units; - } else { - const { loadLedgerFromDisk } = await import("./metrics.js"); - const diskLedger = loadLedgerFromDisk(basePath); - if (!diskLedger || diskLedger.units.length === 0) { - ctx.ui.notify("Nothing to export — no units executed yet.", "info"); - return; - } - units = diskLedger.units; - } - - const projectName = basename(basePath); - const exportDir = gsdRoot(basePath); - mkdirSync(exportDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - - if (format === "json") { - const report = { - exportedAt: new Date().toISOString(), - project: projectName, - totals: getProjectTotals(units), - byPhase: aggregateByPhase(units), - bySlice: aggregateBySlice(units), - byModel: aggregateByModel(units), - units, - }; - const outPath = join(exportDir, `export-${timestamp}.json`); - writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8"); - ctx.ui.notify(`Exported to ${fileLink(outPath)}`, "success"); - } else { - const totals = getProjectTotals(units); - const phases = aggregateByPhase(units); - const slices = aggregateBySlice(units); - - const md = [ - `# SF Session Report — ${projectName}`, - ``, - `**Generated**: ${new Date().toISOString()}`, - `**Units completed**: ${totals.units}`, - `**Total cost**: ${formatCost(totals.cost)}`, - `**Total tokens**: ${formatTokenCount(totals.tokens.total)}`, - `**Total duration**: ${formatDuration(totals.duration)}`, - `**Tool calls**: ${totals.toolCalls}`, - ``, - `## Cost by Phase`, - ``, - `| Phase | Units | Cost | Tokens | Duration |`, - `|-------|-------|------|--------|----------|`, - ...phases.map(p => - `| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`, - ), - ``, - `## Cost by Slice`, - ``, - `| Slice | Units | Cost | Tokens | Duration |`, - `|-------|-------|------|--------|----------|`, - ...slices.map(s => - `| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`, - ), - ``, - `## Unit History`, - ``, - `| Type | ID | Model | Cost | Tokens | Duration |`, - `|------|-----|-------|------|--------|----------|`, - ...units.map(u => - `| ${u.type} | ${u.id} | ${u.model.replace(/^claude-/, "")} | ${formatCost(u.cost)} | ${formatTokenCount(u.tokens.total)} | ${formatDuration(u.finishedAt - u.startedAt)} |`, - ), - ``, - ].join("\n"); - - const outPath = join(exportDir, `export-${timestamp}.md`); - writeFileSync(outPath, md, "utf-8"); - ctx.ui.notify(`Exported to ${fileLink(outPath)}`, "success"); - } -} diff --git a/src/resources/extensions/gsd/extension-manifest.json b/src/resources/extensions/gsd/extension-manifest.json deleted file mode 100644 index 2be2f543a..000000000 --- a/src/resources/extensions/gsd/extension-manifest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "id": "gsd", - "name": "SF Workflow", - "version": "1.0.0", - "description": "Core SF workflow engine — milestone planning, execution, and tracking", - "tier": "core", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "tools": [ - "bash", "write", "read", "edit", - "gsd_decision_save", "gsd_summary_save", - "gsd_requirement_update", "gsd_milestone_generate_id" - ], - "commands": ["gsd", "kill", "worktree", "exit"], - "hooks": [ - "session_start", - "session_switch", - "bash_transform", - "session_fork", - "before_agent_start", - "agent_end", - "session_before_compact", - "session_shutdown", - "tool_call", - "tool_result", - "tool_execution_start", - "tool_execution_end", - "model_select", - "before_provider_request" - ], - "shortcuts": ["Ctrl+Alt+G"] - } -} diff --git a/src/resources/extensions/gsd/file-lock.ts b/src/resources/extensions/gsd/file-lock.ts deleted file mode 100644 index fdf179cf1..000000000 --- a/src/resources/extensions/gsd/file-lock.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { existsSync } from "node:fs"; - -function _require(name: string) { - try { - return require(name); - } catch { - try { - const gsdPiRequire = require("module").createRequire( - require("path").join(process.cwd(), "node_modules", "sf-run", "index.js") - ); - return gsdPiRequire(name); - } catch { - return null; - } - } -} - -export function withFileLockSync(filePath: string, fn: () => T): T { - const lockfile = _require("proper-lockfile"); - if (!lockfile) return fn(); - - if (!existsSync(filePath)) return fn(); - - try { - const release = lockfile.lockSync(filePath, { retries: 5, stale: 10000 }); - try { - return fn(); - } finally { - release(); - } - } catch (err: any) { - if (err.code === "ELOCKED") { - // Could not get lock after retries, let's fallback to un-locked instead of crashing the whole state machine - return fn(); - } - throw err; - } -} - -export async function withFileLock(filePath: string, fn: () => Promise | T): Promise { - const lockfile = _require("proper-lockfile"); - if (!lockfile) return await fn(); - - if (!existsSync(filePath)) return await fn(); - - try { - const release = await lockfile.lock(filePath, { retries: 5, stale: 10000 }); - try { - return await fn(); - } finally { - await release(); - } - } catch (err: any) { - if (err.code === "ELOCKED") { - return await fn(); - } - throw err; - } -} diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts deleted file mode 100644 index dc74338b9..000000000 --- a/src/resources/extensions/gsd/files.ts +++ /dev/null @@ -1,1009 +0,0 @@ -// SF Extension - File Parsing and I/O -// Parsers for roadmap, plan, summary, and continue files. -// Used by state derivation and the status widget. -// Pure functions, zero Pi dependencies - uses only Node built-ins. - -import { promises as fs } from 'node:fs'; -import { resolve } from 'node:path'; -import { atomicWriteAsync } from './atomic-write.js'; -import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './paths.js'; -import { milestoneIdSort, findMilestoneIds } from './milestone-ids.js'; - -import type { - TaskPlanFile, TaskPlanFrontmatter, - Summary, SummaryFrontmatter, SummaryRequires, FileModified, - Continue, ContinueFrontmatter, ContinueStatus, - RequirementCounts, - TaskIO, - SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus, - ManifestStatus, -} from './types.js'; - -import { checkExistingEnvKeys } from './env-utils.js'; -import { nativeExtractSection, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; -import { CACHE_MAX } from './constants.js'; -import { splitFrontmatter, parseFrontmatterMap } from '../shared/frontmatter.js'; - -// Re-export for downstream consumers -export { splitFrontmatter, parseFrontmatterMap }; - -// ─── Parse Cache ────────────────────────────────────────────────────────── - -/** Fast composite key: length + first/mid/last 100 chars. The middle sample - * prevents collisions when only a few characters change in the interior of - * a file (e.g., a checkbox [ ] → [x] that doesn't alter length or endpoints). */ -function cacheKey(content: string): string { - const len = content.length; - const head = content.slice(0, 100); - const midStart = Math.max(0, Math.floor(len / 2) - 50); - const mid = len > 200 ? content.slice(midStart, midStart + 100) : ''; - const tail = len > 100 ? content.slice(-100) : ''; - return `${len}:${head}:${mid}:${tail}`; -} - -const _parseCache = new Map(); - -function cachedParse(content: string, tag: string, parseFn: (c: string) => T): T { - const key = tag + '|' + cacheKey(content); - if (_parseCache.has(key)) return _parseCache.get(key) as T; - if (_parseCache.size >= CACHE_MAX) _parseCache.clear(); - const result = parseFn(content); - _parseCache.set(key, result); - return result; -} - -// ─── Cross-module cache clear registry ──────────────────────────────────── -// parsers-legacy.ts registers its cache-clear callback here at module init -// to avoid circular imports. clearParseCache() calls all registered callbacks. -const _cacheClearCallbacks: (() => void)[] = []; - -/** Register a callback to be invoked when clearParseCache() is called. - * Used by parsers-legacy.ts to synchronously clear its own cache. */ -export function registerCacheClearCallback(cb: () => void): void { - _cacheClearCallbacks.push(cb); -} - -/** Clear the module-scoped parse cache. Call when files change on disk. - * Also clears any registered external caches (e.g. parsers-legacy.ts). */ -export function clearParseCache(): void { - _parseCache.clear(); - for (const cb of _cacheClearCallbacks) cb(); -} - -// ─── Platform shortcuts ─────────────────────────────────────────────────── - -const IS_MAC = process.platform === "darwin"; - -/** - * Format a keyboard shortcut for the current OS. - * Input: modifier key combo like "Ctrl+Alt+G" - * Output: "⌃⌥G" on macOS, "Ctrl+Alt+G" on Windows/Linux. - */ -export function formatShortcut(combo: string): string { - if (!IS_MAC) return combo; - return combo - .replace(/Ctrl\+Alt\+/i, "⌃⌥") - .replace(/Ctrl\+/i, "⌃") - .replace(/Alt\+/i, "⌥") - .replace(/Shift\+/i, "⇧") - .replace(/Cmd\+/i, "⌘"); -} - -// ─── Helpers ─────────────────────────────────────────────────────────────── - -/** Extract the text after a heading at a given level, up to the next heading of same or higher level. */ -export function extractSection(body: string, heading: string, level: number = 2): string | null { - // Try native parser first for better performance on large files - const nativeResult = nativeExtractSection(body, heading, level); - if (nativeResult !== NATIVE_UNAVAILABLE) return nativeResult as string | null; - - const prefix = '#'.repeat(level) + ' '; - const regex = new RegExp(`^${prefix}${escapeRegex(heading)}\\s*$`, 'm'); - const match = regex.exec(body); - if (!match) return null; - - const start = match.index + match[0].length; - const rest = body.slice(start); - - const nextHeading = rest.match(new RegExp(`^#{1,${level}} `, 'm')); - const end = nextHeading ? nextHeading.index! : rest.length; - - return rest.slice(0, end).trim(); -} - -/** Extract all sections at a given level, returning heading → content map. */ -export function extractAllSections(body: string, level: number = 2): Map { - const prefix = '#'.repeat(level) + ' '; - const regex = new RegExp(`^${prefix}(.+)$`, 'gm'); - const sections = new Map(); - const matches = [...body.matchAll(regex)]; - - for (let i = 0; i < matches.length; i++) { - const heading = matches[i][1].trim(); - const start = matches[i].index! + matches[i][0].length; - const end = i + 1 < matches.length ? matches[i + 1].index! : body.length; - sections.set(heading, body.slice(start, end).trim()); - } - - return sections; -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Normalize a task-plan file reference that may include inline description text - * after the path, for example: - * "docs/file.md — explanation" - * "docs/file.md - explanation" - */ -export function normalizePlannedFileReference(value: string): string { - const trimmed = value.trim().replace(/`/g, ""); - const match = /^(.*?)(?:\s+(?:—|-)\s+)(.+)$/.exec(trimmed); - if (!match) return trimmed; - - const pathCandidate = match[1].trim(); - if (pathCandidate.includes("/") || pathCandidate.includes("\\") || pathCandidate.includes(".")) { - return pathCandidate; - } - - return trimmed; -} - -/** Parse bullet list items from a text block. */ -export function parseBullets(text: string): string[] { - return text.split('\n') - .map(l => l.replace(/^\s*[-*]\s+/, '').trim()) - .filter(l => l.length > 0 && !l.startsWith('#')); -} - -/** Extract key: value from bold-prefixed lines like "**Key:** Value" */ -export function extractBoldField(text: string, key: string): string | null { - const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, 'm'); - const match = regex.exec(text); - return match ? match[1].trim() : null; -} - -// ─── Secrets Manifest Parser ─────────────────────────────────────────────── - -const VALID_STATUSES = new Set(['pending', 'collected', 'skipped']); - -export function parseSecretsManifest(content: string): SecretsManifest { - const milestone = extractBoldField(content, 'Milestone') || ''; - const generatedAt = extractBoldField(content, 'Generated') || ''; - - const h3Sections = extractAllSections(content, 3); - const entries: SecretsManifestEntry[] = []; - - for (const [heading, sectionContent] of h3Sections) { - const key = heading.trim(); - if (!key) continue; - - const service = extractBoldField(sectionContent, 'Service') || ''; - const dashboardUrl = extractBoldField(sectionContent, 'Dashboard') || ''; - const formatHint = extractBoldField(sectionContent, 'Format hint') || ''; - const rawStatus = (extractBoldField(sectionContent, 'Status') || 'pending').toLowerCase().trim() as SecretsManifestEntryStatus; - const status: SecretsManifestEntryStatus = VALID_STATUSES.has(rawStatus) ? rawStatus : 'pending'; - const destination = extractBoldField(sectionContent, 'Destination') || 'dotenv'; - - // Extract numbered guidance list (lines matching "1. ...", "2. ...", etc.) - const guidance: string[] = []; - for (const line of sectionContent.split('\n')) { - const numMatch = line.match(/^\s*\d+\.\s+(.+)/); - if (numMatch) { - guidance.push(numMatch[1].trim()); - } - } - - entries.push({ key, service, dashboardUrl, guidance, formatHint, status, destination }); - } - - return { milestone, generatedAt, entries }; -} - -// ─── Secrets Manifest Formatter ─────────────────────────────────────────── - -export function formatSecretsManifest(manifest: SecretsManifest): string { - const lines: string[] = []; - - lines.push('# Secrets Manifest'); - lines.push(''); - lines.push(`**Milestone:** ${manifest.milestone}`); - lines.push(`**Generated:** ${manifest.generatedAt}`); - - for (const entry of manifest.entries) { - lines.push(''); - lines.push(`### ${entry.key}`); - lines.push(''); - lines.push(`**Service:** ${entry.service}`); - if (entry.dashboardUrl) { - lines.push(`**Dashboard:** ${entry.dashboardUrl}`); - } - if (entry.formatHint) { - lines.push(`**Format hint:** ${entry.formatHint}`); - } - lines.push(`**Status:** ${entry.status}`); - lines.push(`**Destination:** ${entry.destination}`); - lines.push(''); - for (let i = 0; i < entry.guidance.length; i++) { - lines.push(`${i + 1}. ${entry.guidance[i]}`); - } - } - - return lines.join('\n') + '\n'; -} - -// ─── Slice Plan Parser ───────────────────────────────────────────────────── - -function normalizeTaskPlanFrontmatter(frontmatter: Record): TaskPlanFrontmatter { - const estimatedStepsRaw = frontmatter.estimated_steps; - const estimatedFilesRaw = frontmatter.estimated_files; - const skillsUsedRaw = frontmatter.skills_used; - - const parseOptionalNumber = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim()) { - const parsed = parseInt(value, 10); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; - }; - - const estimated_steps = parseOptionalNumber(estimatedStepsRaw); - const estimated_files = parseOptionalNumber(estimatedFilesRaw); - const skills_used = Array.isArray(skillsUsedRaw) - ? skillsUsedRaw.map(v => String(v).trim()).filter(Boolean) - : typeof skillsUsedRaw === 'string' && skillsUsedRaw.trim() - ? [skillsUsedRaw.trim()] - : []; - - return { - ...(estimated_steps !== undefined ? { estimated_steps } : {}), - ...(estimated_files !== undefined ? { estimated_files } : {}), - skills_used, - }; -} - -export function parseTaskPlanFile(content: string): TaskPlanFile { - const [fmLines] = splitFrontmatter(content); - const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; - return { - frontmatter: normalizeTaskPlanFrontmatter(fm), - }; -} - -// ─── Summary Parser ──────────────────────────────────────────────────────── - -export function parseSummary(content: string): Summary { - return cachedParse(content, 'summary', _parseSummaryImpl); -} - -function _parseSummaryImpl(content: string): Summary { - // Try native parser first for better performance - const nativeResult = nativeParseSummaryFile(content); - if (nativeResult) { - const nfm = nativeResult.frontmatter; - return { - frontmatter: { - id: nfm.id, - parent: nfm.parent, - milestone: nfm.milestone, - provides: nfm.provides, - requires: nfm.requires, - affects: nfm.affects, - key_files: nfm.keyFiles, - key_decisions: nfm.keyDecisions, - patterns_established: nfm.patternsEstablished, - drill_down_paths: nfm.drillDownPaths, - observability_surfaces: nfm.observabilitySurfaces, - duration: nfm.duration, - verification_result: nfm.verificationResult, - completed_at: nfm.completedAt, - blocker_discovered: nfm.blockerDiscovered, - }, - title: nativeResult.title, - oneLiner: nativeResult.oneLiner, - whatHappened: nativeResult.whatHappened, - deviations: nativeResult.deviations, - filesModified: nativeResult.filesModified, - followUps: extractSection(content, 'Follow-ups') ?? '', - knownLimitations: extractSection(content, 'Known Limitations') ?? '', - }; - } - - const [fmLines, body] = splitFrontmatter(content); - - const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; - const asStringArray = (v: unknown): string[] => - Array.isArray(v) ? v : (typeof v === 'string' && v ? [v] : []); - const frontmatter: SummaryFrontmatter = { - id: (fm.id as string) || '', - parent: (fm.parent as string) || '', - milestone: (fm.milestone as string) || '', - provides: asStringArray(fm.provides), - requires: ((fm.requires as Array>) || []).map(r => ({ - slice: r.slice || '', - provides: r.provides || '', - })), - affects: asStringArray(fm.affects), - key_files: asStringArray(fm.key_files), - key_decisions: asStringArray(fm.key_decisions), - patterns_established: asStringArray(fm.patterns_established), - drill_down_paths: asStringArray(fm.drill_down_paths), - observability_surfaces: asStringArray(fm.observability_surfaces), - duration: (fm.duration as string) || '', - verification_result: (fm.verification_result as string) || 'untested', - completed_at: (fm.completed_at as string) || '', - blocker_discovered: fm.blocker_discovered === 'true' || fm.blocker_discovered === true, - }; - - const bodyLines = body.split('\n'); - const h1 = bodyLines.find(l => l.startsWith('# ')); - const title = h1 ? h1.slice(2).trim() : ''; - - const h1Idx = bodyLines.indexOf(h1 || ''); - let oneLiner = ''; - for (let i = h1Idx + 1; i < bodyLines.length; i++) { - const line = bodyLines[i].trim(); - if (!line) continue; - if (line.startsWith('**') && line.endsWith('**')) { - oneLiner = line.slice(2, -2); - } - break; - } - - const whatHappened = extractSection(body, 'What Happened') || ''; - const deviations = extractSection(body, 'Deviations') || ''; - - const filesSection = extractSection(body, 'Files Created/Modified') || extractSection(body, 'Files Modified'); - const filesModified: FileModified[] = []; - if (filesSection) { - for (const line of filesSection.split('\n')) { - const trimmed = line.replace(/^\s*[-*]\s+/, '').trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const fileMatch = trimmed.match(/^`([^`]+)`\s*[—–-]\s*(.+)/); - if (fileMatch) { - filesModified.push({ path: fileMatch[1], description: fileMatch[2].trim() }); - } - } - } - - const followUps = extractSection(body, 'Follow-ups') ?? ''; - const knownLimitations = extractSection(body, 'Known Limitations') ?? ''; - - return { frontmatter, title, oneLiner, whatHappened, deviations, filesModified, followUps, knownLimitations }; -} - -// ─── Continue Parser ─────────────────────────────────────────────────────── - -export function parseContinue(content: string): Continue { - return cachedParse(content, 'continue', _parseContinueImpl); -} - -function _parseContinueImpl(content: string): Continue { - const [fmLines, body] = splitFrontmatter(content); - - const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; - const frontmatter: ContinueFrontmatter = { - milestone: (fm.milestone as string) || '', - slice: (fm.slice as string) || '', - task: (fm.task as string) || '', - step: typeof fm.step === 'string' ? parseInt(fm.step) : (fm.step as number) || 0, - totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps) : (fm.total_steps as number) || - (typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps) : (fm.totalSteps as number) || 0), - status: ((fm.status as string) || 'in_progress') as ContinueStatus, - savedAt: (fm.saved_at as string) || (fm.savedAt as string) || '', - }; - - const completedWork = extractSection(body, 'Completed Work') || ''; - const remainingWork = extractSection(body, 'Remaining Work') || ''; - const decisions = extractSection(body, 'Decisions Made') || ''; - const context = extractSection(body, 'Context') || ''; - const nextAction = extractSection(body, 'Next Action') || ''; - - return { frontmatter, completedWork, remainingWork, decisions, context, nextAction }; -} - -// ─── Continue Formatter ──────────────────────────────────────────────────── - -function formatFrontmatter(data: Record): string { - const lines: string[] = ['---']; - - for (const [key, value] of Object.entries(data)) { - if (value === undefined || value === null) continue; - - if (Array.isArray(value)) { - if (value.length === 0) { - lines.push(`${key}: []`); - } else if (typeof value[0] === 'object' && value[0] !== null) { - lines.push(`${key}:`); - for (const obj of value) { - const entries = Object.entries(obj as Record); - if (entries.length > 0) { - lines.push(` - ${entries[0][0]}: ${entries[0][1]}`); - for (let i = 1; i < entries.length; i++) { - lines.push(` ${entries[i][0]}: ${entries[i][1]}`); - } - } - } - } else { - lines.push(`${key}:`); - for (const item of value) { - lines.push(` - ${item}`); - } - } - } else { - lines.push(`${key}: ${value}`); - } - } - - lines.push('---'); - return lines.join('\n'); -} - -export function formatContinue(cont: Continue): string { - const fm = cont.frontmatter; - const fmData: Record = { - milestone: fm.milestone, - slice: fm.slice, - task: fm.task, - step: fm.step, - total_steps: fm.totalSteps, - status: fm.status, - saved_at: fm.savedAt, - }; - - const lines: string[] = []; - lines.push(formatFrontmatter(fmData)); - lines.push(''); - lines.push('## Completed Work'); - lines.push(cont.completedWork); - lines.push(''); - lines.push('## Remaining Work'); - lines.push(cont.remainingWork); - lines.push(''); - lines.push('## Decisions Made'); - lines.push(cont.decisions); - lines.push(''); - lines.push('## Context'); - lines.push(cont.context); - lines.push(''); - lines.push('## Next Action'); - lines.push(cont.nextAction); - - return lines.join('\n'); -} - -// ─── File I/O ────────────────────────────────────────────────────────────── - -/** - * Load a file from disk. Returns content string or null if file doesn't exist. - */ -export async function loadFile(path: string): Promise { - try { - return await fs.readFile(path, 'utf-8'); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'EISDIR') return null; - throw err; - } -} - -/** - * Save content to a file atomically (write to temp, then rename). - * Creates parent directories if needed. - */ -export async function saveFile(path: string, content: string): Promise { - await atomicWriteAsync(path, content); -} - -export function parseRequirementCounts(content: string | null): RequirementCounts { - const counts: RequirementCounts = { - active: 0, - validated: 0, - deferred: 0, - outOfScope: 0, - blocked: 0, - total: 0, - }; - - if (!content) return counts; - - const sections = [ - { key: 'active', heading: 'Active' }, - { key: 'validated', heading: 'Validated' }, - { key: 'deferred', heading: 'Deferred' }, - { key: 'outOfScope', heading: 'Out of Scope' }, - ] as const; - - for (const section of sections) { - const text = extractSection(content, section.heading, 2); - if (!text) continue; - const matches = text.match(/^###\s+[A-Z][\w-]*\d+\s+—/gm); - counts[section.key] = matches ? matches.length : 0; - } - - const blockedMatches = content.match(/^-\s+Status:\s+blocked\s*$/gim); - counts.blocked = blockedMatches ? blockedMatches.length : 0; - counts.total = counts.active + counts.validated + counts.deferred + counts.outOfScope; - return counts; -} - -// ─── Task Plan Must-Haves Parser ─────────────────────────────────────────── - -/** - * Parse must-have items from a task plan's `## Must-Haves` section. - * Returns structured items with checkbox state. Handles YAML frontmatter, - * all common checkbox variants (`[ ]`, `[x]`, `[X]`), plain bullets (no checkbox), - * and indented variants. Returns empty array when the section is missing or empty. - */ -export function parseTaskPlanMustHaves(content: string): Array<{ text: string; checked: boolean }> { - const [, body] = splitFrontmatter(content); - const sectionText = extractSection(body, 'Must-Haves'); - if (!sectionText) return []; - - const bullets = parseBullets(sectionText); - if (bullets.length === 0) return []; - - return bullets.map(line => { - const cbMatch = line.match(/^\[([xX ])\]\s+(.+)/); - if (cbMatch) { - return { - text: cbMatch[2].trim(), - checked: cbMatch[1].toLowerCase() === 'x', - }; - } - // No checkbox - treat as unchecked with full line as text - return { text: line.trim(), checked: false }; - }); -} - -// ─── Must-Have Summary Matching ──────────────────────────────────────────── - -/** Common short words to exclude from substring matching. */ -const COMMON_WORDS = new Set([ - 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', - 'was', 'one', 'our', 'out', 'has', 'its', 'let', 'say', 'she', 'too', 'use', - 'with', 'have', 'from', 'this', 'that', 'they', 'been', 'each', 'when', 'will', - 'does', 'into', 'also', 'than', 'them', 'then', 'some', 'what', 'only', 'just', - 'more', 'make', 'like', 'made', 'over', 'such', 'take', 'most', 'very', 'must', - 'file', 'test', 'tests', 'task', 'new', 'add', 'added', 'existing', -]); - -/** - * Count how many must-have items are mentioned in a summary. - * - * Matching heuristic per must-have: - * 1. Extract all backtick-enclosed code tokens (e.g. `inspectFoo`). - * If any code token appears case-insensitively in the summary, count as mentioned. - * 2. If no code tokens exist, check if any significant word (≥4 chars, not a common word) - * from the must-have text appears in the summary (case-insensitive). - * - * Returns the count of must-haves that had at least one match. - */ -export function countMustHavesMentionedInSummary( - mustHaves: Array<{ text: string; checked: boolean }>, - summaryContent: string, -): number { - if (!summaryContent || mustHaves.length === 0) return 0; - - const summaryLower = summaryContent.toLowerCase(); - let count = 0; - - for (const mh of mustHaves) { - // Extract backtick-enclosed code tokens - const codeTokens: string[] = []; - const codeRegex = /`([^`]+)`/g; - let match: RegExpExecArray | null; - while ((match = codeRegex.exec(mh.text)) !== null) { - codeTokens.push(match[1]); - } - - if (codeTokens.length > 0) { - // Strategy 1: any code token found in summary (case-insensitive) - const found = codeTokens.some(token => summaryLower.includes(token.toLowerCase())); - if (found) count++; - } else { - // Strategy 2: significant substring matching - // Split into words, keep words ≥4 chars that aren't common - const words = mh.text.replace(/[^\w\s]/g, ' ').split(/\s+/).filter(w => - w.length >= 4 && !COMMON_WORDS.has(w.toLowerCase()) - ); - const found = words.some(word => summaryLower.includes(word.toLowerCase())); - if (found) count++; - } - } - - return count; -} - -// ─── Task Plan IO Extractor ──────────────────────────────────────────────── - -/** - * Extract input and output file paths from a task plan's `## Inputs` and - * `## Expected Output` sections. Looks for backtick-wrapped file paths on - * each line (e.g. `` `src/foo.ts` ``). - * - * Returns empty arrays for missing/empty sections — callers should treat - * tasks with no IO as ambiguous (sequential fallback trigger). - */ -export function parseTaskPlanIO(content: string): { inputFiles: string[]; outputFiles: string[] } { - const backtickPathRegex = /`([^`]+)`/g; - - function extractPaths(sectionText: string | null): string[] { - if (!sectionText) return []; - const paths: string[] = []; - for (const line of sectionText.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - let match: RegExpExecArray | null; - backtickPathRegex.lastIndex = 0; - while ((match = backtickPathRegex.exec(trimmed)) !== null) { - const candidate = normalizePlannedFileReference(match[1]); - // Filter out things that look like code tokens rather than file paths - // (e.g. `true`, `false`, `npm run test`). A file path has at least one - // dot or slash. - if (candidate.includes("/") || candidate.includes("\\") || candidate.includes(".")) { - paths.push(candidate); - } - } - } - return paths; - } - - const [, body] = splitFrontmatter(content); - const inputSection = extractSection(body, "Inputs"); - const outputSection = extractSection(body, "Expected Output"); - - return { - inputFiles: extractPaths(inputSection), - outputFiles: extractPaths(outputSection), - }; -} - -// ─── UAT Type Extractor ──────────────────────────────────────────────────── - -/** - * The four UAT classification types recognised by SF auto-mode. - * `undefined` is returned (not this union) when no type can be determined. - */ -export type UatType = 'artifact-driven' | 'live-runtime' | 'human-experience' | 'mixed' | 'browser-executable' | 'runtime-executable'; - -/** - * Extract the UAT type from a UAT file's raw content. - * - * UAT files have no YAML frontmatter - pass raw file content directly. - * Classification is leading-keyword-only: e.g. `mixed (artifact-driven + live-runtime)` → `'mixed'`. - * - * Returns `undefined` when: - * - the `## UAT Type` section is absent - * - no `UAT mode:` bullet is found in the section - * - the value does not start with a recognised keyword - */ -export function extractUatType(content: string): UatType | undefined { - const sectionText = extractSection(content, 'UAT Type'); - if (!sectionText) return undefined; - - const bullets = parseBullets(sectionText); - const modeBullet = bullets.find(b => b.startsWith('UAT mode:')); - if (!modeBullet) return undefined; - - const rawValue = modeBullet.slice('UAT mode:'.length).trim().toLowerCase(); - - if (rawValue.startsWith('artifact-driven')) return 'artifact-driven'; - if (rawValue.startsWith('browser-executable')) return 'browser-executable'; - if (rawValue.startsWith('runtime-executable')) return 'runtime-executable'; - if (rawValue.startsWith('live-runtime')) return 'live-runtime'; - if (rawValue.startsWith('human-experience')) return 'human-experience'; - if (rawValue.startsWith('mixed')) return 'mixed'; - - return undefined; -} - -/** - * Extract the `depends_on` list from M00x-CONTEXT.md YAML frontmatter. - * Returns [] when: content is null, no frontmatter block, field absent, or field is empty. - * Normalizes each dep ID to uppercase (e.g. 'm001' → 'M001'). - */ -export function parseContextDependsOn(content: string | null): string[] { - if (!content) return []; - const [fmLines] = splitFrontmatter(content); - if (!fmLines) return []; - const fm = parseFrontmatterMap(fmLines); - const raw = fm['depends_on']; - if (!Array.isArray(raw) || raw.length === 0) return []; - return (raw as string[]).map(s => String(s).trim()).filter(Boolean); -} - -/** - * Inline the prior milestone's SUMMARY.md as context for the current milestone's planning prompt. - * Returns null when: (1) `mid` is the first milestone, (2) prior milestone has no SUMMARY file. - * - * Uses the shared findMilestoneIds to scan the milestones directory. - */ -export async function inlinePriorMilestoneSummary(mid: string, base: string): Promise { - const sorted = findMilestoneIds(base); - if (sorted.length === 0) return null; - const idx = sorted.indexOf(mid); - if (idx <= 0) return null; - const prevMid = sorted[idx - 1]; - const absPath = resolveMilestoneFile(base, prevMid, "SUMMARY"); - const relPath = relMilestoneFile(base, prevMid, "SUMMARY"); - const content = absPath ? await loadFile(absPath) : null; - if (!content) return null; - return `### Prior Milestone Summary\nSource: \`${relPath}\`\n\n${content.trim()}`; -} - -// ─── Manifest Status ────────────────────────────────────────────────────── - -/** - * Read a secrets manifest from disk and cross-reference each entry's status - * with the current environment (.env + process.env). - * - * Returns `null` when no manifest file exists (path resolution failure or - * file not on disk) - callers can distinguish "no manifest" from "empty manifest". - */ -export async function getManifestStatus( - base: string, milestoneId: string, projectRoot?: string, -): Promise { - const resolvedPath = resolveMilestoneFile(base, milestoneId, 'SECRETS'); - if (!resolvedPath) return null; - - const content = await loadFile(resolvedPath); - if (!content) return null; - - const manifest = parseSecretsManifest(content); - const keys = manifest.entries.map(e => e.key); - - // Check both the base path .env AND the project root .env (#1387). - // In worktree mode, base is the worktree path which may not have .env. - // The project root's .env is where the user actually defined their keys. - const existingKeys = await checkExistingEnvKeys(keys, resolve(base, '.env')); - const existingSet = new Set(existingKeys); - - if (projectRoot && projectRoot !== base) { - const rootKeys = await checkExistingEnvKeys(keys, resolve(projectRoot, '.env')); - for (const k of rootKeys) existingSet.add(k); - } - - const result: ManifestStatus = { - pending: [], - collected: [], - skipped: [], - existing: [], - }; - - for (const entry of manifest.entries) { - if (existingSet.has(entry.key)) { - result.existing.push(entry.key); - } else { - result[entry.status].push(entry.key); - } - } - - return result; -} - -// ─── Overrides ────────────────────────────────────────────────────────────── - -export interface Override { - timestamp: string; - change: string; - scope: "active" | "resolved"; - appliedAt: string; -} - -export async function appendOverride(basePath: string, change: string, appliedAt: string): Promise { - const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); - const timestamp = new Date().toISOString(); - const entry = [ - `## Override: ${timestamp}`, - "", - `**Change:** ${change}`, - `**Scope:** active`, - `**Applied-at:** ${appliedAt}`, - "", - "---", - "", - ].join("\n"); - - const existing = await loadFile(overridesPath); - if (existing) { - await saveFile(overridesPath, existing.trimEnd() + "\n\n" + entry); - } else { - const header = [ - "# SF Overrides", - "", - "User-issued overrides that supersede plan document content.", - "", - "---", - "", - ].join("\n"); - await saveFile(overridesPath, header + entry); - } -} - -export async function appendKnowledge( - basePath: string, - type: "rule" | "pattern" | "lesson", - entry: string, - scope: string, -): Promise { - const knowledgePath = resolveGsdRootFile(basePath, "KNOWLEDGE"); - const existing = await loadFile(knowledgePath); - - if (existing) { - // Find the next ID for this type - const prefix = type === "rule" ? "K" : type === "pattern" ? "P" : "L"; - const idPattern = new RegExp(`^\\| ${prefix}(\\d+)`, "gm"); - let maxId = 0; - let match; - while ((match = idPattern.exec(existing)) !== null) { - const num = parseInt(match[1], 10); - if (num > maxId) maxId = num; - } - const nextId = `${prefix}${String(maxId + 1).padStart(3, "0")}`; - - // Build the table row - let row: string; - if (type === "rule") { - row = `| ${nextId} | ${scope} | ${entry} | — | manual |`; - } else if (type === "pattern") { - row = `| ${nextId} | ${entry} | — | ${scope} |`; - } else { - row = `| ${nextId} | ${entry} | — | — | ${scope} |`; - } - - // Find the right section and append after the table header - const sectionHeading = type === "rule" ? "## Rules" : type === "pattern" ? "## Patterns" : "## Lessons Learned"; - const sectionIdx = existing.indexOf(sectionHeading); - if (sectionIdx !== -1) { - // Find the end of the table header row (the |---|...| line) - const afterHeading = existing.indexOf("\n", sectionIdx); - // Find the next section or end - const nextSection = existing.indexOf("\n## ", afterHeading + 1); - const insertPoint = nextSection !== -1 ? nextSection : existing.length; - - // Insert row before the next section (or at end) - const before = existing.slice(0, insertPoint).trimEnd(); - const after = existing.slice(insertPoint); - await saveFile(knowledgePath, before + "\n" + row + "\n" + after); - } else { - // Section not found — append at end - await saveFile(knowledgePath, existing.trimEnd() + "\n\n" + row + "\n"); - } - } else { - // Create file from scratch with template header - const header = [ - "# Project Knowledge", - "", - "Append-only register of project-specific rules, patterns, and lessons learned.", - "Agents read this before every unit. Add entries when you discover something worth remembering.", - "", - ].join("\n"); - - let content: string; - if (type === "rule") { - content = header + [ - "## Rules", - "", - "| # | Scope | Rule | Why | Added |", - "|---|-------|------|-----|-------|", - `| K001 | ${scope} | ${entry} | — | manual |`, - "", - "## Patterns", - "", - "| # | Pattern | Where | Notes |", - "|---|---------|-------|-------|", - "", - "## Lessons Learned", - "", - "| # | What Happened | Root Cause | Fix | Scope |", - "|---|--------------|------------|-----|-------|", - "", - ].join("\n"); - } else if (type === "pattern") { - content = header + [ - "## Rules", - "", - "| # | Scope | Rule | Why | Added |", - "|---|-------|------|-----|-------|", - "", - "## Patterns", - "", - "| # | Pattern | Where | Notes |", - "|---|---------|-------|-------|", - `| P001 | ${entry} | — | ${scope} |`, - "", - "## Lessons Learned", - "", - "| # | What Happened | Root Cause | Fix | Scope |", - "|---|--------------|------------|-----|-------|", - "", - ].join("\n"); - } else { - content = header + [ - "## Rules", - "", - "| # | Scope | Rule | Why | Added |", - "|---|-------|------|-----|-------|", - "", - "## Patterns", - "", - "| # | Pattern | Where | Notes |", - "|---|---------|-------|-------|", - "", - "## Lessons Learned", - "", - "| # | What Happened | Root Cause | Fix | Scope |", - "|---|--------------|------------|-----|-------|", - `| L001 | ${entry} | — | — | ${scope} |`, - "", - ].join("\n"); - } - await saveFile(knowledgePath, content); - } -} - -export async function loadActiveOverrides(basePath: string): Promise { - const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); - const content = await loadFile(overridesPath); - if (!content) return []; - return parseOverrides(content).filter(o => o.scope === "active"); -} - -export function parseOverrides(content: string): Override[] { - const overrides: Override[] = []; - const blocks = content.split(/^## Override: /m).slice(1); - - for (const block of blocks) { - const lines = block.split("\n"); - const timestamp = lines[0]?.trim() ?? ""; - let change = ""; - let scope: "active" | "resolved" = "active"; - let appliedAt = ""; - - for (const line of lines) { - const changeMatch = line.match(/^\*\*Change:\*\*\s*(.+)$/); - if (changeMatch) change = changeMatch[1].trim(); - const scopeMatch = line.match(/^\*\*Scope:\*\*\s*(.+)$/); - if (scopeMatch) scope = scopeMatch[1].trim() as "active" | "resolved"; - const appliedMatch = line.match(/^\*\*Applied-at:\*\*\s*(.+)$/); - if (appliedMatch) appliedAt = appliedMatch[1].trim(); - } - - if (change) { - overrides.push({ timestamp, change, scope, appliedAt }); - } - } - - return overrides; -} - -export function formatOverridesSection(overrides: Override[]): string { - if (overrides.length === 0) return ""; - - const entries = overrides.map((o, i) => [ - `${i + 1}. **${o.change}**`, - ` _Issued: ${o.timestamp} during ${o.appliedAt}_`, - ].join("\n")).join("\n"); - - return [ - "## Active Overrides (supersede plan content)", - "", - "The following overrides were issued by the user and supersede any conflicting content in plan documents below. Follow these overrides even if they contradict the inlined task plan.", - "", - entries, - "", - ].join("\n"); -} - -export async function resolveAllOverrides(basePath: string): Promise { - const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); - const content = await loadFile(overridesPath); - if (!content) return; - const updated = content.replace(/\*\*Scope:\*\* active/g, "**Scope:** resolved"); - await saveFile(overridesPath, updated); -} diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts deleted file mode 100644 index 009d2f94b..000000000 --- a/src/resources/extensions/gsd/forensics.ts +++ /dev/null @@ -1,1210 +0,0 @@ -/** - * SF Forensics — Post-mortem investigation of auto-mode failures - * - * Programmatically scans activity logs, metrics, crash locks, and doctor - * diagnostics for anomalies, then hands a structured report to the LLM - * for interactive investigation. - * - * Entry point: handleForensics() called from commands.ts - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@sf-run/pi-coding-agent"; -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { join, dirname, relative } from "node:path"; -import { fileURLToPath } from "node:url"; -import { homedir } from "node:os"; - -import { extractTrace, type ExecutionTrace } from "./session-forensics.js"; -import { nativeParseJsonlTail } from "./native-parser-bridge.js"; -import { MAX_JSONL_BYTES, parseJSONL } from "./jsonl-utils.js"; -import { - loadLedgerFromDisk, getAverageCostPerUnitType, getProjectTotals, - formatCost, formatTokenCount, type UnitMetrics, type MetricsLedger, -} from "./metrics.js"; -import { readCrashLock, isLockProcessAlive, formatCrashInfo, type LockData } from "./crash-recovery.js"; -import { runGSDDoctor, formatDoctorIssuesForPrompt, type DoctorIssue } from "./doctor.js"; -import { verifyExpectedArtifact } from "./auto-recovery.js"; -import { deriveState } from "./state.js"; -import { isAutoActive } from "./auto.js"; -import { loadPrompt } from "./prompt-loader.js"; -import { gsdRoot } from "./paths.js"; -import { isDbAvailable, getAllMilestones, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; -import { isClosedStatus } from "./status-guards.js"; -import { formatDuration } from "../shared/format-utils.js"; -import { getAutoWorktreePath } from "./auto-worktree.js"; -import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js"; -import { showNextAction } from "../shared/tui.js"; -import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface ForensicAnomaly { - type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure"; - severity: "info" | "warning" | "error"; - unitType?: string; - unitId?: string; - summary: string; - details: string; -} - -interface UnitTrace { - file: string; - unitType: string; - unitId: string; - seq: number; - trace: ExecutionTrace; - mtime: number; -} - -/** Summary of .gsd/activity/ directory metadata. */ -interface ActivityLogMeta { - fileCount: number; - totalSizeBytes: number; - oldestFile: string | null; - newestFile: string | null; -} - -/** - * Summary of .gsd/journal/ data for forensic investigation. - * - * To avoid loading huge journal histories into memory, only the most recent - * daily files are fully parsed. Older files are line-counted for totals. - * Event counts and flow IDs reflect only recent files. - */ -interface JournalSummary { - /** Total journal entries across all files (recent parsed + older line-counted) */ - totalEntries: number; - /** Distinct flow IDs from recent files (each = one auto-mode iteration) */ - flowCount: number; - /** Event counts by type (from recent files only) */ - eventCounts: Record; - /** Most recent journal entries (last 20) for context */ - recentEvents: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[]; - /** Date range of journal data */ - oldestEntry: string | null; - newestEntry: string | null; - /** Daily file count */ - fileCount: number; -} - -interface DbCompletionCounts { - milestones: number; - milestonesTotal: number; - slices: number; - slicesTotal: number; - tasks: number; - tasksTotal: number; -} - -interface ForensicReport { - gsdVersion: string; - timestamp: string; - basePath: string; - activeMilestone: string | null; - activeSlice: string | null; - activeWorktree: string | null; - unitTraces: UnitTrace[]; - metrics: MetricsLedger | null; - completedKeys: string[]; - dbCompletionCounts: DbCompletionCounts | null; - crashLock: LockData | null; - doctorIssues: DoctorIssue[]; - anomalies: ForensicAnomaly[]; - recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[]; - journalSummary: JournalSummary | null; - activityLogMeta: ActivityLogMeta | null; -} - -// ─── Duplicate Detection ────────────────────────────────────────────────────── - -const DEDUP_PROMPT_SECTION = ` -## Pre-Investigation: Duplicate Check (REQUIRED) - -Before reading SF source code or performing deep analysis, you MUST search for existing issues and PRs that may already address this bug. This avoids wasting tokens on already-fixed bugs. - -### Search Steps - -Use keywords from the user's problem description and the anomaly summaries in the forensic report above. - -1. **Search closed issues** for similar keywords: - \`\`\` - gh issue list --repo singularity-forge/sf-run --state closed --search "" --limit 20 - \`\`\` - -2. **Search open PRs** that might contain the fix: - \`\`\` - gh pr list --repo singularity-forge/sf-run --state open --search "" --limit 10 - \`\`\` - -3. **Search merged PRs** that may have already fixed this: - \`\`\` - gh pr list --repo singularity-forge/sf-run --state merged --search "" --limit 10 - \`\`\` - -### Analysis - -For each result, compare it against the user's reported symptoms and the forensic anomalies: -- Does the issue describe the same code path or file? -- Does the PR modify the area related to the reported symptoms? -- Is the symptom description semantically similar even if keywords differ? - -### Decision Gate - -- **Merged PR clearly fixes the described symptom** → Report "Already fixed by PR #X" with brief explanation. Skip full investigation. -- **Open issue matches** → Report "Existing issue #Y covers this." Offer to add forensic evidence. Skip full investigation unless user asks for deeper analysis. -- **No matches** → Proceed to full investigation below. -`; - -async function writeForensicsDedupPref(ctx: ExtensionCommandContext, enabled: boolean): Promise { - const prefsPath = getGlobalGSDPreferencesPath(); - await ensurePreferencesFile(prefsPath, ctx, "global"); - const existing = loadGlobalGSDPreferences(); - const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; - prefs.version = prefs.version || 1; - prefs.forensics_dedup = enabled; - - const frontmatter = serializePreferencesToFrontmatter(prefs); - const raw = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : ""; - let body = "\n# SF Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - const start = raw.startsWith("---\n") ? 4 : raw.startsWith("---\r\n") ? 5 : -1; - if (start !== -1) { - const closingIdx = raw.indexOf("\n---", start); - if (closingIdx !== -1) { - const after = raw.slice(closingIdx + 4); - if (after.trim()) body = after; - } - } - - writeFileSync(prefsPath, `---\n${frontmatter}---${body}`, "utf-8"); -} - -// ─── Entry Point ────────────────────────────────────────────────────────────── - -export async function handleForensics( - args: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, -): Promise { - if (isAutoActive()) { - ctx.ui.notify("Cannot run forensics while auto-mode is active. Stop auto-mode first.", "error"); - return; - } - - const basePath = process.cwd(); - const root = gsdRoot(basePath); - if (!existsSync(root)) { - ctx.ui.notify("No SF state found. Run /gsd auto first.", "warning"); - return; - } - - let problemDescription = args.trim(); - if (!problemDescription) { - problemDescription = await ctx.ui.input( - "Describe what went wrong:", - "e.g. auto-mode got stuck on task T03", - ) ?? ""; - } - if (!problemDescription?.trim()) { - ctx.ui.notify("Problem description required for forensic analysis.", "warning"); - return; - } - - // ─── Duplicate detection opt-in ───────────────────────────────────────────── - const effectivePrefs = loadEffectiveGSDPreferences()?.preferences; - let dedupEnabled = effectivePrefs?.forensics_dedup === true; - - if (effectivePrefs?.forensics_dedup === undefined) { - const choice = await showNextAction(ctx, { - title: "Duplicate detection available", - summary: ["Before filing a GitHub issue, forensics can search existing issues and PRs to avoid duplicates.", "This uses additional AI tokens for analysis."], - actions: [ - { id: "enable", label: "Enable duplicate detection", description: "Search issues/PRs before filing (recommended)", recommended: true }, - { id: "skip", label: "Skip for now", description: "File without checking for duplicates" }, - ], - notYetMessage: "You can enable this later via preferences (forensics_dedup: true).", - }); - - if (choice === "enable") { - await writeForensicsDedupPref(ctx, true); - dedupEnabled = true; - } - } - - const dedupSection = dedupEnabled ? DEDUP_PROMPT_SECTION : ""; - - ctx.ui.notify("Building forensic report...", "info"); - - const report = await buildForensicReport(basePath); - const savedPath = saveForensicReport(basePath, report, problemDescription); - - // Derive SF source dir for prompt — fall back to ~/.gsd/agent/extensions/gsd/ - // when import.meta.url resolves to the npm-global install path (Windows). - let gsdSourceDir = dirname(fileURLToPath(import.meta.url)); - if (!existsSync(join(gsdSourceDir, "prompts"))) { - const gsdHome = process.env.SF_HOME || join(homedir(), ".gsd"); - const fallback = join(gsdHome, "agent", "extensions", "gsd"); - if (existsSync(join(fallback, "prompts"))) gsdSourceDir = fallback; - } - - const forensicData = formatReportForPrompt(report); - const content = loadPrompt("forensics", { - problemDescription, - forensicData, - gsdSourceDir, - dedupSection, - }); - - ctx.ui.notify(`Forensic report saved: ${relative(basePath, savedPath)}`, "info"); - - pi.sendMessage( - { customType: "gsd-forensics", content, display: false }, - { triggerTurn: true }, - ); - - // Persist forensics context so follow-up turns can re-inject it (#2941) - writeForensicsMarker(basePath, savedPath, content); -} - -// ─── Report Builder ─────────────────────────────────────────────────────────── - -export async function buildForensicReport(basePath: string): Promise { - const anomalies: ForensicAnomaly[] = []; - - // 1. Derive current state - let activeMilestone: string | null = null; - let activeSlice: string | null = null; - try { - const state = await deriveState(basePath); - activeMilestone = state.activeMilestone?.id ?? null; - activeSlice = state.activeSlice?.id ?? null; - } catch { /* state derivation failure is non-fatal */ } - - // 1b. Check for active auto-worktree - const activeWorktree = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null; - - // 2. Scan activity logs (last 5) — worktree-aware - const unitTraces = scanActivityLogs(basePath, activeMilestone); - - // 3. Load metrics - const metrics = loadLedgerFromDisk(basePath); - - // 4. Load completed keys (legacy) and DB completion counts - const completedKeys = loadCompletedKeys(basePath); - const dbCompletionCounts = getDbCompletionCounts(); - - // 5. Check crash lock - const crashLock = readCrashLock(basePath); - - // 6. Run doctor - let doctorIssues: DoctorIssue[] = []; - try { - const report = await runGSDDoctor(basePath, { scope: undefined }); - doctorIssues = report.issues; - } catch { /* doctor failure is non-fatal */ } - - // 7. Build recent units from metrics - const recentUnits: ForensicReport["recentUnits"] = []; - if (metrics?.units) { - const sorted = [...metrics.units].sort((a, b) => b.finishedAt - a.finishedAt).slice(0, 10); - for (const u of sorted) { - recentUnits.push({ - type: u.type, - id: u.id, - cost: u.cost, - duration: u.finishedAt - u.startedAt, - model: u.model, - finishedAt: u.finishedAt, - }); - } - } - - // 8. SF version — use SF_VERSION env var set by the loader at startup. - // Extensions run from ~/.gsd/agent/extensions/gsd/ at runtime, so path-traversal - // from import.meta.url would resolve to ~/package.json (wrong on every system). - const gsdVersion = process.env.SF_VERSION || "unknown"; - - // 9. Scan journal for flow timeline and structured events - const journalSummary = scanJournalForForensics(basePath); - - // 10. Gather activity log directory metadata - const activityLogMeta = gatherActivityLogMeta(basePath, activeMilestone); - - // 11. Run anomaly detectors - if (metrics?.units) detectStuckLoops(metrics.units, anomalies); - if (metrics?.units) detectCostSpikes(metrics.units, anomalies); - detectTimeouts(unitTraces, anomalies); - detectMissingArtifacts(completedKeys, basePath, activeMilestone, anomalies); - detectCrash(crashLock, anomalies); - detectDoctorIssues(doctorIssues, anomalies); - detectErrorTraces(unitTraces, anomalies); - detectJournalAnomalies(journalSummary, anomalies); - - return { - gsdVersion, - timestamp: new Date().toISOString(), - basePath, - activeMilestone, - activeSlice, - activeWorktree: activeWorktree ? relative(basePath, activeWorktree) : null, - unitTraces, - metrics, - completedKeys, - dbCompletionCounts, - crashLock, - doctorIssues, - anomalies, - recentUnits, - journalSummary, - activityLogMeta, - }; -} - -// ─── Activity Log Scanner ───────────────────────────────────────────────────── - -const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/; - -/** Threshold below which iteration cadence is considered rapid (thrashing). */ -const RAPID_ITERATION_THRESHOLD_MS = 5000; - -function scanActivityLogs(basePath: string, activeMilestone?: string | null): UnitTrace[] { - const activityDirs = resolveActivityDirs(basePath, activeMilestone); - const allTraces: UnitTrace[] = []; - - for (const activityDir of activityDirs) { - if (!existsSync(activityDir)) continue; - - const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort(); - const lastFiles = files.slice(-5); - - for (const file of lastFiles) { - const match = ACTIVITY_FILENAME_RE.exec(file); - if (!match) continue; - - const seq = parseInt(match[1]!, 10); - const unitType = match[2]!; - const unitId = match[3]!; - const filePath = join(activityDir, file); - - let entries: unknown[] = []; - const nativeResult = nativeParseJsonlTail(filePath, MAX_JSONL_BYTES); - if (nativeResult) { - entries = nativeResult.entries; - } else { - try { - const raw = readFileSync(filePath, "utf-8"); - entries = parseJSONL(raw); - } catch { continue; } - } - - const trace = extractTrace(entries); - const stat = statSync(filePath, { throwIfNoEntry: false }); - - allTraces.push({ - file: activityDirs.length > 1 ? `[${relative(basePath, activityDir)}] ${file}` : file, - unitType, - unitId, - seq, - trace, - mtime: stat?.mtimeMs ?? 0, - }); - } - } - - // Sort by mtime descending so the most recent traces (regardless of source) come first - return allTraces.sort((a, b) => b.mtime - a.mtime).slice(0, 5); -} - -/** - * Resolve activity directories to scan for forensics. - * If an active auto-worktree exists for the milestone, its activity dir - * is included first (preferred) so stale root logs don't mask worktree progress. - */ -function resolveActivityDirs(basePath: string, activeMilestone?: string | null): string[] { - const dirs: string[] = []; - - // Check for active auto-worktree activity logs - if (activeMilestone) { - const wtPath = getAutoWorktreePath(basePath, activeMilestone); - if (wtPath) { - const wtActivityDir = join(gsdRoot(wtPath), "activity"); - if (existsSync(wtActivityDir)) { - dirs.push(wtActivityDir); - } - } - } - - // Always include root activity logs - const rootActivityDir = join(gsdRoot(basePath), "activity"); - dirs.push(rootActivityDir); - - return dirs; -} - -// ─── Journal Scanner ────────────────────────────────────────────────────────── - -/** - * Max recent journal files to fully parse for event counts and recent events. - * Older files are line-counted only to avoid loading huge amounts of data. - */ -const MAX_JOURNAL_RECENT_FILES = 3; - -/** Max recent events to extract for the forensic report timeline. */ -const MAX_JOURNAL_RECENT_EVENTS = 20; - -/** - * Intelligently scan journal files for forensic summary. - * - * Journal files can be huge (thousands of JSONL entries over weeks of auto-mode). - * Instead of loading all entries into memory: - * - Only fully parse the most recent N daily files (event counts, flow tracking) - * - Line-count older files for approximate totals (no JSON parsing) - * - Extract only the last 20 events for the timeline - */ -function scanJournalForForensics(basePath: string): JournalSummary | null { - try { - const journalDir = join(gsdRoot(basePath), "journal"); - if (!existsSync(journalDir)) return null; - - const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort(); - if (files.length === 0) return null; - - // Split into recent (fully parsed) and older (line-counted only) - const recentFiles = files.slice(-MAX_JOURNAL_RECENT_FILES); - const olderFiles = files.slice(0, -MAX_JOURNAL_RECENT_FILES); - - // Line-count older files without parsing — avoids loading megabytes of JSON - let olderEntryCount = 0; - let oldestEntry: string | null = null; - for (const file of olderFiles) { - try { - const raw = readFileSync(join(journalDir, file), "utf-8"); - const lines = raw.split("\n"); - for (const line of lines) { - if (!line.trim()) continue; - olderEntryCount++; - // Extract only the timestamp from the first non-empty line of the oldest file - if (!oldestEntry) { - try { - const parsed = JSON.parse(line) as { ts?: string }; - if (parsed.ts) oldestEntry = parsed.ts; - } catch { /* skip malformed */ } - } - } - } catch { /* skip unreadable files */ } - } - - // Fully parse recent files for event counts and timeline - const eventCounts: Record = {}; - const flowIds = new Set(); - const recentParsedEntries: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[] = []; - let recentEntryCount = 0; - - for (const file of recentFiles) { - try { - const raw = readFileSync(join(journalDir, file), "utf-8"); - for (const line of raw.split("\n")) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as { ts: string; flowId: string; eventType: string; rule?: string; data?: Record }; - recentEntryCount++; - eventCounts[entry.eventType] = (eventCounts[entry.eventType] ?? 0) + 1; - flowIds.add(entry.flowId); - - if (!oldestEntry) oldestEntry = entry.ts; - - // Keep a rolling window of last N events — avoids accumulating unbounded arrays - recentParsedEntries.push({ - ts: entry.ts, - flowId: entry.flowId, - eventType: entry.eventType, - rule: entry.rule, - unitId: entry.data?.unitId as string | undefined, - }); - if (recentParsedEntries.length > MAX_JOURNAL_RECENT_EVENTS) { - recentParsedEntries.shift(); - } - } catch { /* skip malformed lines */ } - } - } catch { /* skip unreadable files */ } - } - - const totalEntries = olderEntryCount + recentEntryCount; - if (totalEntries === 0) return null; - - const newestEntry = recentParsedEntries.length > 0 - ? recentParsedEntries[recentParsedEntries.length - 1]!.ts - : null; - - return { - totalEntries, - flowCount: flowIds.size, - eventCounts, - recentEvents: recentParsedEntries, - oldestEntry, - newestEntry, - fileCount: files.length, - }; - } catch { - return null; - } -} - -// ─── Activity Log Metadata ──────────────────────────────────────────────────── - -function gatherActivityLogMeta(basePath: string, activeMilestone?: string | null): ActivityLogMeta | null { - try { - const activityDirs = resolveActivityDirs(basePath, activeMilestone); - let fileCount = 0; - let totalSizeBytes = 0; - let oldestFile: string | null = null; - let newestFile: string | null = null; - let oldestMtime = Infinity; - let newestMtime = 0; - - for (const activityDir of activityDirs) { - if (!existsSync(activityDir)) continue; - const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")); - for (const file of files) { - const filePath = join(activityDir, file); - const stat = statSync(filePath, { throwIfNoEntry: false }); - if (!stat) continue; - fileCount++; - totalSizeBytes += stat.size; - if (stat.mtimeMs < oldestMtime) { - oldestMtime = stat.mtimeMs; - oldestFile = file; - } - if (stat.mtimeMs > newestMtime) { - newestMtime = stat.mtimeMs; - newestFile = file; - } - } - } - - if (fileCount === 0) return null; - return { fileCount, totalSizeBytes, oldestFile, newestFile }; - } catch { - return null; - } -} - -// ─── Completed Keys Loader ──────────────────────────────────────────────────── - -function loadCompletedKeys(basePath: string): string[] { - const file = join(gsdRoot(basePath), "completed-units.json"); - try { - if (existsSync(file)) { - return JSON.parse(readFileSync(file, "utf-8")); - } - } catch { /* non-fatal */ } - return []; -} - -// ─── DB Completion Counts ──────────────────────────────────────────────────── - -function getDbCompletionCounts(): DbCompletionCounts | null { - if (!isDbAvailable()) return null; - - const milestones = getAllMilestones(); - let completedMilestones = 0; - let totalSlices = 0; - let completedSlices = 0; - let totalTasks = 0; - let completedTasks = 0; - - for (const m of milestones) { - if (isClosedStatus(m.status)) completedMilestones++; - - const slices = getMilestoneSlices(m.id); - for (const s of slices) { - totalSlices++; - if (isClosedStatus(s.status)) completedSlices++; - - const tasks = getSliceTasks(m.id, s.id); - for (const t of tasks) { - totalTasks++; - if (isClosedStatus(t.status)) completedTasks++; - } - } - } - - return { - milestones: completedMilestones, - milestonesTotal: milestones.length, - slices: completedSlices, - slicesTotal: totalSlices, - tasks: completedTasks, - tasksTotal: totalTasks, - }; -} - -// ─── Anomaly Detectors ─────────────────────────────────────────────────────── - -/** - * Detect units that were dispatched multiple times (stuck in a loop). - * - * Counts distinct dispatches by grouping on (type, id, startedAt) first to - * collapse idle-watchdog duplicate snapshots (#1943), then counts unique - * startedAt values per type/id to determine actual dispatch count. - * - * Exported for testability. - */ -export function detectStuckLoops(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void { - // First, collect unique startedAt values per type/id key, bucketed by - // autoSessionKey when available so cross-session recovery does not look - // like a within-session stuck loop. - const dispatchMap = new Map>>(); - for (const u of units) { - const key = `${u.type}/${u.id}`; - let sessionBuckets = dispatchMap.get(key); - if (!sessionBuckets) { - sessionBuckets = new Map(); - dispatchMap.set(key, sessionBuckets); - } - - const sessionKey = u.autoSessionKey ?? "__legacy__"; - let starts = sessionBuckets.get(sessionKey); - if (!starts) { - starts = new Set(); - sessionBuckets.set(sessionKey, starts); - } - starts.add(u.startedAt); - } - - for (const [key, sessionBuckets] of dispatchMap) { - const hasSessionAwareData = Array.from(sessionBuckets.keys()).some((sessionKey) => sessionKey !== "__legacy__"); - const count = hasSessionAwareData - ? Math.max(...Array.from(sessionBuckets.values(), (starts) => starts.size)) - : (sessionBuckets.get("__legacy__")?.size ?? 0); - - if (count > 1) { - const [unitType, ...idParts] = key.split("/"); - anomalies.push({ - type: "stuck-loop", - severity: count >= 3 ? "error" : "warning", - unitType, - unitId: idParts.join("/"), - summary: `Unit ${key} was dispatched ${count} times`, - details: hasSessionAwareData - ? `Repeated dispatch within the same auto session suggests the unit completed but its artifacts were not verified, or the state machine kept returning it. Cross-session recovery runs are ignored.` - : `Repeated dispatch suggests the unit completed but its artifacts weren't verified, or the state machine kept returning it.`, - }); - } - } -} - -function detectCostSpikes(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void { - const avgMap = getAverageCostPerUnitType(units); - for (const u of units) { - const avg = avgMap.get(u.type); - if (avg && avg > 0 && u.cost > avg * 3) { - anomalies.push({ - type: "cost-spike", - severity: "warning", - unitType: u.type, - unitId: u.id, - summary: `${formatCost(u.cost)} vs ${formatCost(avg)} average for ${u.type}`, - details: `Unit ${u.type}/${u.id} cost ${(u.cost / avg).toFixed(1)}x the average. May indicate excessive retries or large context.`, - }); - } - } -} - -function detectTimeouts(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void { - for (const ut of traces) { - // Check for timeout-recovery custom messages in tool calls - const hasTimeout = ut.trace.toolCalls.some(tc => - tc.name === "sendmessage" && - JSON.stringify(tc.input).includes("gsd-auto-timeout-recovery"), - ); - // Check for timeout keywords in last reasoning - const reasoningTimeout = ut.trace.lastReasoning && - /(?:idle.?timeout|hard.?timeout|timeout.?recovery)/i.test(ut.trace.lastReasoning); - - if (hasTimeout || reasoningTimeout) { - anomalies.push({ - type: "timeout", - severity: "warning", - unitType: ut.unitType, - unitId: ut.unitId, - summary: `Timeout detected in ${ut.unitType}/${ut.unitId}`, - details: `Activity log ${ut.file} contains timeout recovery patterns. The unit may have stalled.`, - }); - } - } -} - -/** - * Parse a completed-unit key into its unitType and unitId. - * - * Hook units use a compound slash-delimited type ("hook/"), so a - * naive `key.indexOf("/")` would split "hook/telegram-progress/M007/S01" into - * unitType="hook" (wrong) instead of "hook/telegram-progress". - * - * Returns `null` for malformed keys that cannot be split. - */ -export function splitCompletedKey(key: string): { unitType: string; unitId: string } | null { - if (key.startsWith("hook/")) { - // Hook unit types are two segments: "hook//" - const secondSlash = key.indexOf("/", 5); // skip past "hook/" - if (secondSlash === -1) return null; // malformed — no unitId after hook name - return { - unitType: key.slice(0, secondSlash), - unitId: key.slice(secondSlash + 1), - }; - } - - const slashIdx = key.indexOf("/"); - if (slashIdx === -1) return null; - return { - unitType: key.slice(0, slashIdx), - unitId: key.slice(slashIdx + 1), - }; -} - -function detectMissingArtifacts(completedKeys: string[], basePath: string, activeMilestone: string | null, anomalies: ForensicAnomaly[]): void { - // Also check the worktree path for artifacts — they may exist there but not at root - const wtBasePath = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null; - - for (const key of completedKeys) { - const parsed = splitCompletedKey(key); - if (!parsed) continue; - const { unitType, unitId } = parsed; - - const rootHasArtifact = verifyExpectedArtifact(unitType, unitId, basePath); - const wtHasArtifact = wtBasePath ? verifyExpectedArtifact(unitType, unitId, wtBasePath) : false; - - if (!rootHasArtifact && !wtHasArtifact) { - anomalies.push({ - type: "missing-artifact", - severity: "error", - unitType, - unitId, - summary: `Completed key ${key} but artifact missing or invalid`, - details: `The unit is recorded as completed but verifyExpectedArtifact() returns false at both project root and worktree. The completion state is stale.`, - }); - } - } -} - -function detectCrash(crashLock: LockData | null, anomalies: ForensicAnomaly[]): void { - if (!crashLock) return; - if (isLockProcessAlive(crashLock)) return; // Process still running, not a crash - - anomalies.push({ - type: "crash", - severity: "error", - unitType: crashLock.unitType, - unitId: crashLock.unitId, - summary: `Stale crash lock: PID ${crashLock.pid} is dead`, - details: formatCrashInfo(crashLock), - }); -} - -function detectDoctorIssues(issues: DoctorIssue[], anomalies: ForensicAnomaly[]): void { - for (const issue of issues) { - if (issue.severity === "error") { - anomalies.push({ - type: "doctor-issue", - severity: "error", - summary: `Doctor: ${issue.message}`, - details: `Code: ${issue.code}, Scope: ${issue.scope}, Unit: ${issue.unitId}${issue.file ? `, File: ${issue.file}` : ""}`, - }); - } - } -} - -function detectErrorTraces(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void { - for (const ut of traces) { - if (ut.trace.errors.length > 0) { - anomalies.push({ - type: "error-trace", - severity: "warning", - unitType: ut.unitType, - unitId: ut.unitId, - summary: `${ut.trace.errors.length} error(s) in ${ut.unitType}/${ut.unitId}`, - details: ut.trace.errors.slice(0, 3).join("\n"), - }); - } - } -} - -function detectJournalAnomalies(journal: JournalSummary | null, anomalies: ForensicAnomaly[]): void { - if (!journal) return; - - // Detect stuck-detected events from the journal - const stuckCount = journal.eventCounts["stuck-detected"] ?? 0; - if (stuckCount > 0) { - anomalies.push({ - type: "journal-stuck", - severity: stuckCount >= 3 ? "error" : "warning", - summary: `Journal recorded ${stuckCount} stuck-detected event(s)`, - details: `The auto-mode loop detected it was stuck ${stuckCount} time(s). Check journal events for flow IDs and causal chains to trace the root cause.`, - }); - } - - // Detect guard-block events (dispatch was blocked by a guard) - const guardCount = journal.eventCounts["guard-block"] ?? 0; - if (guardCount > 0) { - anomalies.push({ - type: "journal-guard-block", - severity: guardCount >= 5 ? "warning" : "info", - summary: `Journal recorded ${guardCount} guard-block event(s)`, - details: `Dispatch was blocked by a guard condition ${guardCount} time(s). This may indicate a persistent blocking condition preventing progress.`, - }); - } - - // Detect rapid iterations (many flows in short time = likely thrashing) - if (journal.flowCount > 0 && journal.oldestEntry && journal.newestEntry) { - const oldest = new Date(journal.oldestEntry).getTime(); - const newest = new Date(journal.newestEntry).getTime(); - const spanMs = newest - oldest; - if (spanMs > 0 && journal.flowCount > 10) { - const avgMs = spanMs / journal.flowCount; - if (avgMs < RAPID_ITERATION_THRESHOLD_MS) { - anomalies.push({ - type: "journal-rapid-iterations", - severity: "warning", - summary: `${journal.flowCount} iterations in ${formatDuration(spanMs)} (avg ${formatDuration(avgMs)}/iteration)`, - details: `Unusually rapid iteration cadence suggests the loop may be thrashing without making progress. Review recent journal events for dispatch-stop or terminal events.`, - }); - } - } - } - - // Detect worktree failures from journal events - const wtCreateFailed = journal.eventCounts["worktree-create-failed"] ?? 0; - const wtMergeFailed = journal.eventCounts["worktree-merge-failed"] ?? 0; - const wtFailures = wtCreateFailed + wtMergeFailed; - if (wtFailures > 0) { - const parts: string[] = []; - if (wtCreateFailed > 0) parts.push(`${wtCreateFailed} create failure(s)`); - if (wtMergeFailed > 0) parts.push(`${wtMergeFailed} merge failure(s)`); - anomalies.push({ - type: "journal-worktree-failure", - severity: "warning", - summary: `Worktree failures: ${parts.join(", ")}`, - details: `Journal recorded worktree operation failures. These may indicate git state corruption or conflicting branches.`, - }); - } -} - -// ─── Report Persistence ─────────────────────────────────────────────────────── - -function saveForensicReport(basePath: string, report: ForensicReport, problemDescription: string): string { - const dir = join(gsdRoot(basePath), "forensics"); - mkdirSync(dir, { recursive: true }); - - const ts = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").slice(0, 19); - const filePath = join(dir, `report-${ts}.md`); - - const redact = (s: string) => redactForGitHub(s, basePath); - - const sections: string[] = [ - `# SF Forensic Report`, - ``, - `**Generated:** ${report.timestamp}`, - `**SF Version:** ${report.gsdVersion}`, - `**Active Milestone:** ${report.activeMilestone ?? "none"}`, - `**Active Slice:** ${report.activeSlice ?? "none"}`, - `**Active Worktree:** ${report.activeWorktree ?? "none"}`, - ``, - `## Problem Description`, - ``, - problemDescription, - ``, - ]; - - // Anomalies - if (report.anomalies.length > 0) { - sections.push(`## Anomalies Detected (${report.anomalies.length})`, ``); - for (const a of report.anomalies) { - sections.push(`### [${a.severity.toUpperCase()}] ${a.type}: ${a.summary}`); - if (a.unitType) sections.push(`- Unit: ${a.unitType}/${a.unitId ?? ""}`); - sections.push(`- ${redact(a.details)}`, ``); - } - } else { - sections.push(`## Anomalies`, ``, `No anomalies detected.`, ``); - } - - // Recent units - if (report.recentUnits.length > 0) { - sections.push(`## Recent Units`, ``); - sections.push(`| Type | ID | Cost | Duration | Model |`); - sections.push(`|------|-----|------|----------|-------|`); - for (const u of report.recentUnits) { - sections.push(`| ${u.type} | ${u.id} | ${formatCost(u.cost)} | ${formatDuration(u.duration)} | ${u.model} |`); - } - sections.push(``); - } - - // Unit traces - if (report.unitTraces.length > 0) { - sections.push(`## Activity Log Traces (last ${report.unitTraces.length})`, ``); - for (const ut of report.unitTraces) { - sections.push(`### ${ut.unitType}/${ut.unitId} (seq ${ut.seq})`); - sections.push(`- Tool calls: ${ut.trace.toolCallCount}`); - sections.push(`- Files written: ${ut.trace.filesWritten.length}`); - sections.push(`- Errors: ${ut.trace.errors.length}`); - if (ut.trace.lastReasoning) { - sections.push(`- Last reasoning: ${redact(ut.trace.lastReasoning.slice(0, 200))}`); - } - sections.push(``); - } - } - - // Doctor issues - if (report.doctorIssues.length > 0) { - sections.push(`## Doctor Issues`, ``); - sections.push(formatDoctorIssuesForPrompt(report.doctorIssues), ``); - } - - // Crash lock - if (report.crashLock) { - sections.push(`## Crash Lock`, ``); - sections.push(redact(formatCrashInfo(report.crashLock)), ``); - } - - // Activity log metadata - if (report.activityLogMeta) { - const meta = report.activityLogMeta; - sections.push(`## Activity Log Metadata`, ``); - sections.push(`- Files: ${meta.fileCount}`); - sections.push(`- Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`); - if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`); - if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`); - sections.push(``); - } - - // Journal summary - if (report.journalSummary) { - const js = report.journalSummary; - sections.push(`## Journal Summary`, ``); - sections.push(`- Total entries: ${js.totalEntries}`); - sections.push(`- Distinct flows (iterations): ${js.flowCount}`); - sections.push(`- Daily files: ${js.fileCount}`); - if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry} — ${js.newestEntry}`); - sections.push(``); - sections.push(`### Event Type Distribution`, ``); - sections.push(`| Event Type | Count |`); - sections.push(`|------------|-------|`); - for (const [evType, count] of Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1])) { - sections.push(`| ${evType} | ${count} |`); - } - sections.push(``); - if (js.recentEvents.length > 0) { - sections.push(`### Recent Journal Events (last ${js.recentEvents.length})`, ``); - for (const ev of js.recentEvents) { - const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`]; - if (ev.rule) parts.push(`rule=${ev.rule}`); - if (ev.unitId) parts.push(`unit=${ev.unitId}`); - sections.push(`- ${parts.join(" ")}`); - } - sections.push(``); - } - } - - writeFileSync(filePath, sections.join("\n"), "utf-8"); - return filePath; -} - -// ─── Forensics Session Marker ──────────────────────────────────────────────── - -export interface ForensicsMarker { - reportPath: string; - promptContent: string; - createdAt: string; -} - -/** - * Write a marker file so that buildBeforeAgentStartResult() can re-inject - * the forensics prompt on follow-up turns. (#2941) - */ -export function writeForensicsMarker(basePath: string, reportPath: string, promptContent: string): void { - const dir = join(gsdRoot(basePath), "runtime"); - mkdirSync(dir, { recursive: true }); - const marker: ForensicsMarker = { - reportPath, - promptContent, - createdAt: new Date().toISOString(), - }; - writeFileSync(join(dir, "active-forensics.json"), JSON.stringify(marker), "utf-8"); -} - -/** - * Read the active forensics marker, or null if none exists. - */ -export function readForensicsMarker(basePath: string): ForensicsMarker | null { - const markerPath = join(gsdRoot(basePath), "runtime", "active-forensics.json"); - if (!existsSync(markerPath)) return null; - try { - return JSON.parse(readFileSync(markerPath, "utf-8")) as ForensicsMarker; - } catch { - return null; - } -} - -// ─── Prompt Formatter ───────────────────────────────────────────────────────── - -function formatReportForPrompt(report: ForensicReport): string { - const MAX_BYTES = 30 * 1024; - const sections: string[] = []; - - // Anomalies (most important, first) - sections.push(`### Anomalies (${report.anomalies.length})`); - if (report.anomalies.length === 0) { - sections.push("No anomalies detected."); - } else { - for (const a of report.anomalies) { - sections.push(`- **[${a.severity.toUpperCase()}] ${a.type}**: ${a.summary}`); - if (a.details) sections.push(` ${a.details.slice(0, 300)}`); - } - } - sections.push(""); - - // Recent unit history - if (report.recentUnits.length > 0) { - sections.push(`### Recent Units (last ${report.recentUnits.length})`); - sections.push("| Type | ID | Cost | Duration | Model |"); - sections.push("|------|-----|------|----------|-------|"); - for (const u of report.recentUnits) { - sections.push(`| ${u.type} | ${u.id} | ${formatCost(u.cost)} | ${formatDuration(u.duration)} | ${u.model} |`); - } - sections.push(""); - } - - // Trace summaries (last 3) - const recentTraces = report.unitTraces.slice(0, 3); - if (recentTraces.length > 0) { - sections.push(`### Activity Log Traces (last ${recentTraces.length})`); - for (const ut of recentTraces) { - sections.push(`**${ut.unitType}/${ut.unitId}** (seq ${ut.seq})`); - sections.push(`- Tool calls: ${ut.trace.toolCallCount}, Errors: ${ut.trace.errors.length}`); - if (ut.trace.filesWritten.length > 0) { - sections.push(`- Files written: ${ut.trace.filesWritten.slice(0, 5).join(", ")}`); - } - if (ut.trace.errors.length > 0) { - sections.push(`- Errors: ${ut.trace.errors.slice(0, 2).map(e => e.slice(0, 200)).join("; ")}`); - } - if (ut.trace.lastReasoning) { - sections.push(`- Last reasoning: "${ut.trace.lastReasoning.slice(0, 300)}"`); - } - sections.push(""); - } - } - - // Doctor issues (error severity only) - const errorIssues = report.doctorIssues.filter(i => i.severity === "error"); - if (errorIssues.length > 0) { - sections.push(`### Doctor Issues (${errorIssues.length} errors)`); - sections.push(formatDoctorIssuesForPrompt(errorIssues)); - sections.push(""); - } - - // Crash lock - if (report.crashLock) { - sections.push("### Crash Lock"); - sections.push(formatCrashInfo(report.crashLock)); - const alive = isLockProcessAlive(report.crashLock); - sections.push(`Process alive: ${alive}`); - sections.push(""); - } - - // Metrics summary - if (report.metrics?.units) { - const totals = getProjectTotals(report.metrics.units); - sections.push("### Metrics Summary"); - sections.push(`- Total units: ${totals.units}`); - sections.push(`- Total cost: ${formatCost(totals.cost)}`); - sections.push(`- Total tokens: ${formatTokenCount(totals.tokens.total)}`); - sections.push(`- Total duration: ${formatDuration(totals.duration)}`); - sections.push(""); - } - - // Activity log metadata - if (report.activityLogMeta) { - const meta = report.activityLogMeta; - sections.push("### Activity Log Overview"); - sections.push(`- Files: ${meta.fileCount}, Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`); - if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`); - if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`); - sections.push(""); - } - - // Journal summary — structured event timeline - if (report.journalSummary) { - const js = report.journalSummary; - sections.push("### Journal Summary (Iteration Event Log)"); - sections.push(`- Total entries: ${js.totalEntries}, Distinct flows: ${js.flowCount}, Daily files: ${js.fileCount}`); - if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry} — ${js.newestEntry}`); - - // Event type distribution (compact) - const eventPairs = Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1]); - sections.push(`- Events: ${eventPairs.map(([t, c]) => `${t}(${c})`).join(", ")}`); - - // Recent events timeline (for tracing what just happened) - if (js.recentEvents.length > 0) { - sections.push(""); - sections.push(`**Recent Journal Events (last ${js.recentEvents.length}):**`); - for (const ev of js.recentEvents) { - const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`]; - if (ev.rule) parts.push(`rule=${ev.rule}`); - if (ev.unitId) parts.push(`unit=${ev.unitId}`); - sections.push(`- ${parts.join(" ")}`); - } - } - sections.push(""); - } - - // Completion status — prefer DB counts, fall back to legacy completed-units.json - if (report.dbCompletionCounts) { - const c = report.dbCompletionCounts; - sections.push(`### Completion Status (from DB)`); - sections.push(`- ${c.milestones}/${c.milestonesTotal} milestones complete`); - sections.push(`- ${c.slices}/${c.slicesTotal} slices complete`); - sections.push(`- ${c.tasks}/${c.tasksTotal} tasks complete`); - } else { - sections.push(`### Completed Keys: ${report.completedKeys.length}`); - } - sections.push(`### SF Version: ${report.gsdVersion}`); - sections.push(`### Active Milestone: ${report.activeMilestone ?? "none"}`); - sections.push(`### Active Slice: ${report.activeSlice ?? "none"}`); - if (report.activeWorktree) { - sections.push(`### Active Worktree: ${report.activeWorktree}`); - sections.push(`Note: Activity logs were scanned from both the worktree and the project root. Worktree logs take priority.`); - } - - let result = sections.join("\n"); - if (result.length > MAX_BYTES) { - result = result.slice(0, MAX_BYTES) + "\n\n[... truncated at 30KB ...]"; - } - return result; -} - -// ─── Redaction ──────────────────────────────────────────────────────────────── - -function redactForGitHub(text: string, basePath: string): string { - let result = text; - - // Replace absolute paths - result = result.replaceAll(basePath, "."); - const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; - if (home) result = result.replaceAll(home, "~"); - - // Strip API key patterns - result = result.replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***"); - result = result.replace(/Bearer\s+\S+/g, "Bearer ***"); - - // Strip env var assignments - result = result.replace(/[A-Z_]{2,}=\S+/g, (match) => { - const eq = match.indexOf("="); - return match.slice(0, eq + 1) + "***"; - }); - - // Truncate long lines - result = result.split("\n").map(line => - line.length > 500 ? line.slice(0, 497) + "..." : line, - ).join("\n"); - - return result; -} diff --git a/src/resources/extensions/gsd/gate-registry.ts b/src/resources/extensions/gsd/gate-registry.ts deleted file mode 100644 index 844e8f710..000000000 --- a/src/resources/extensions/gsd/gate-registry.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * SF Gate Registry — single source of truth for quality-gate ownership. - * - * Each gate declares which workflow turn owns it, the scope at which it is - * persisted in the `quality_gates` table, and the question/guidance text used - * in the prompt that turn sends. The registry replaces the ad-hoc - * `GATE_QUESTIONS` table that used to live in `auto-prompts.ts`, and every - * layer of the prompt system (prompt builders, dispatch rules, state - * derivation, tool handlers) consults it so a pending gate can never be - * silently dropped. - * - * Design notes: - * - `GATE_REGISTRY` is exhaustiveness-checked against `GateId` via - * `satisfies Record`, so adding a new GateId - * without a registry entry is a compile error. - * - `getGatesForTurn(turn)` returns the definitions a turn owns. - * - `assertGateCoverage(pending, turn)` throws a GSDError if the pending - * list for a turn contains unknown gates, or if any gate owned by the - * turn is missing from the pending list. - */ - -import { GSDError, SF_PARSE_ERROR } from "./errors.js"; -import type { GateId, GateRow, GateScope } from "./types.js"; - -/** Which workflow turn is responsible for evaluating / closing a gate. */ -export type OwnerTurn = - | "gate-evaluate" - | "execute-task" - | "complete-slice" - | "validate-milestone"; - -export interface GateDefinition { - id: GateId; - scope: GateScope; - ownerTurn: OwnerTurn; - /** One-line question the assistant must answer. */ - question: string; - /** Markdown guidance describing what a good answer looks like. */ - guidance: string; - /** H3 section header used in the artifact the turn writes - * (e.g. "Operational Readiness" for Q8 in the slice summary). */ - promptSection: string; -} - -export const GATE_REGISTRY = { - Q3: { - id: "Q3", - scope: "slice", - ownerTurn: "gate-evaluate", - question: "How can this be exploited?", - guidance: [ - "Identify abuse scenarios: parameter tampering, replay attacks, privilege escalation.", - "Map data exposure risks: PII, tokens, secrets accessible through this slice.", - "Define input trust boundaries: untrusted user input reaching DB, API, or filesystem.", - "If none apply, return verdict 'omitted' with rationale explaining why.", - ].join("\n"), - promptSection: "Abuse Surface", - }, - Q4: { - id: "Q4", - scope: "slice", - ownerTurn: "gate-evaluate", - question: "What existing promises does this break?", - guidance: [ - "List which existing requirements (R001, R003, etc.) are touched by this slice.", - "Identify what must be re-tested after shipping.", - "Flag decisions that should be revisited given the new scope.", - "If no existing requirements are affected, return verdict 'omitted'.", - ].join("\n"), - promptSection: "Broken Promises", - }, - Q5: { - id: "Q5", - scope: "task", - ownerTurn: "execute-task", - question: "What breaks when dependencies fail?", - guidance: [ - "Enumerate the task's external dependencies (APIs, filesystem, network, subprocesses).", - "Describe the failure path for each: timeout, malformed response, connection loss.", - "Verify the implementation handles each failure or explicitly bubbles the error.", - "Return verdict 'omitted' only if the task has no external dependencies.", - ].join("\n"), - promptSection: "Failure Modes", - }, - Q6: { - id: "Q6", - scope: "task", - ownerTurn: "execute-task", - question: "What is the 10x load breakpoint?", - guidance: [ - "Identify the resource that saturates first at 10x the expected load.", - "Describe the protection applied (pool sizing, rate limiting, pagination, caching).", - "Return verdict 'omitted' if the task has no runtime load dimension.", - ].join("\n"), - promptSection: "Load Profile", - }, - Q7: { - id: "Q7", - scope: "task", - ownerTurn: "execute-task", - question: "What negative tests protect this task?", - guidance: [ - "List malformed inputs, error paths, and boundary conditions the tests cover.", - "Point to the specific test files or cases that assert each negative scenario.", - "Return verdict 'omitted' only if the task has no meaningful negative surface.", - ].join("\n"), - promptSection: "Negative Tests", - }, - Q8: { - id: "Q8", - scope: "slice", - ownerTurn: "complete-slice", - question: "How will ops know this slice is healthy or broken?", - guidance: [ - "Describe the health signal (metric, log line, dashboard) that proves the slice works.", - "Describe the failure signal that triggers an alert or paging.", - "Document the recovery procedure and any monitoring gaps.", - "Return verdict 'omitted' only for slices with no runtime behavior at all.", - ].join("\n"), - promptSection: "Operational Readiness", - }, - MV01: { - id: "MV01", - scope: "milestone", - ownerTurn: "validate-milestone", - question: "Is every success criterion in the milestone roadmap satisfied?", - guidance: [ - "Walk the success-criteria checklist from the milestone roadmap.", - "For each criterion, point to the slice / assessment / verification evidence that proves it.", - "Return verdict 'flag' if any criterion is unmet or unverifiable.", - ].join("\n"), - promptSection: "Success Criteria Checklist", - }, - MV02: { - id: "MV02", - scope: "milestone", - ownerTurn: "validate-milestone", - question: "Does every slice have a SUMMARY.md and a passing assessment?", - guidance: [ - "Confirm every slice listed in the roadmap has a SUMMARY.md.", - "Confirm each slice has an ASSESSMENT verdict of 'pass' (or justified 'omitted').", - "Flag missing artifacts and slices with outstanding follow-ups or known limitations.", - ].join("\n"), - promptSection: "Slice Delivery Audit", - }, - MV03: { - id: "MV03", - scope: "milestone", - ownerTurn: "validate-milestone", - question: "Do the slices integrate end-to-end?", - guidance: [ - "Trace at least one cross-slice flow proving the pieces compose.", - "Flag gaps where two slices were built in isolation with no integration evidence.", - ].join("\n"), - promptSection: "Cross-Slice Integration", - }, - MV04: { - id: "MV04", - scope: "milestone", - ownerTurn: "validate-milestone", - question: "Are all touched requirements covered and still coherent?", - guidance: [ - "For each requirement advanced, validated, surfaced, or invalidated across the milestone's slices, confirm the milestone-level evidence matches.", - "Flag requirements that slices claim to advance but no artifact proves.", - ].join("\n"), - promptSection: "Requirement Coverage", - }, -} as const satisfies Record; - -export type GateRegistry = typeof GATE_REGISTRY; - -/** Stable ordered lists per owner turn — iteration order matches declaration. */ -const ORDERED_GATES: readonly GateDefinition[] = Object.values(GATE_REGISTRY) as readonly GateDefinition[]; - -/** Return every gate owned by a turn, in stable declaration order. */ -export function getGatesForTurn(turn: OwnerTurn): GateDefinition[] { - return ORDERED_GATES.filter((g) => g.ownerTurn === turn); -} - -/** Return the set of gate ids a turn owns. */ -export function getGateIdsForTurn(turn: OwnerTurn): Set { - return new Set(getGatesForTurn(turn).map((g) => g.id)); -} - -/** Look up a definition by gate id, or undefined if unknown. */ -export function getGateDefinition(id: string): GateDefinition | undefined { - return (GATE_REGISTRY as Record)[id]; -} - -/** Look up the owner turn for a gate id. Throws if the gate is unknown. */ -export function getOwnerTurn(id: GateId): OwnerTurn { - const def = GATE_REGISTRY[id]; - if (!def) { - throw new GSDError(SF_PARSE_ERROR, `gate-registry: unknown gate id "${id}"`); - } - return def.ownerTurn; -} - -/** - * Assert that the pending gate rows for a turn match what the registry says - * the turn owns. Fails loudly rather than silently skipping. - * - * - Every row in `pending` must have a definition whose `ownerTurn` matches `turn`. - * (The caller is responsible for scoping the pending list — e.g. filtering - * by slice scope before passing it in.) - * - `options.requireAll` (default true): every gate the turn owns must appear - * in `pending`. Set to false for turns like `execute-task` that only need - * coverage for the subset of gates that were seeded (e.g. tasks with no - * external dependencies have no Q5 row). - */ -export function assertGateCoverage( - pending: ReadonlyArray>, - turn: OwnerTurn, - options: { requireAll?: boolean } = {}, -): void { - const requireAll = options.requireAll ?? true; - const expected = getGateIdsForTurn(turn); - const pendingIds = new Set(pending.map((g) => g.gate_id)); - - const unknown: string[] = []; - for (const id of pendingIds) { - const def = getGateDefinition(id); - if (!def) { - unknown.push(id); - continue; - } - if (def.ownerTurn !== turn) { - unknown.push(`${id} (owned by ${def.ownerTurn}, not ${turn})`); - } - } - - if (unknown.length > 0) { - throw new GSDError( - SF_PARSE_ERROR, - `assertGateCoverage: turn "${turn}" received pending gates it does not own: ${unknown.join(", ")}`, - ); - } - - if (requireAll) { - const missing: GateId[] = []; - for (const id of expected) { - if (!pendingIds.has(id)) missing.push(id); - } - if (missing.length > 0) { - throw new GSDError( - SF_PARSE_ERROR, - `assertGateCoverage: turn "${turn}" is missing required gates: ${missing.join(", ")}`, - ); - } - } -} diff --git a/src/resources/extensions/gsd/git-constants.ts b/src/resources/extensions/gsd/git-constants.ts deleted file mode 100644 index 4925f4271..000000000 --- a/src/resources/extensions/gsd/git-constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Shared git constants used across git-service and native-git-bridge. - */ - -/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ -export const GIT_NO_PROMPT_ENV = { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_ASKPASS: "", - GIT_SVN_ID: "", - LC_ALL: "C", // force English git output so stderr string checks work on all locales (#1997) -}; diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts deleted file mode 100644 index efe8d894d..000000000 --- a/src/resources/extensions/gsd/git-self-heal.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * git-self-heal.ts — Automated git state recovery utilities. - * - * Four synchronous functions for recovering from broken git state - * during auto-mode operations. Uses only `git reset --hard HEAD` — - * never `git clean` (which would delete untracked .gsd/ dirs). - * - * Observability: Each function returns structured results describing - * what actions were taken. `formatGitError` maps raw git errors to - * user-friendly messages suggesting `/gsd doctor`. - */ - -import { existsSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { MergeConflictError } from "./git-service.js"; -import { nativeMergeAbort, nativeRebaseAbort, nativeResetHard } from "./native-git-bridge.js"; - -// Re-export for consumers -export { MergeConflictError }; - -/** Result from abortAndReset describing what was cleaned up. */ -export interface AbortAndResetResult { - /** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */ - cleaned: string[]; -} - -/** - * Detect and clean up leftover merge/rebase state, then hard-reset. - * - * Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply. - * Aborts in-progress merge or rebase if detected. Always finishes - * with `git reset --hard HEAD`. - * - * @returns Structured result listing what was cleaned. Empty `cleaned` - * array means repo was already in a clean state. - */ -export function abortAndReset(cwd: string): AbortAndResetResult { - const gitDir = join(cwd, ".git"); - const cleaned: string[] = []; - - // Abort in-progress merge - if (existsSync(join(gitDir, "MERGE_HEAD"))) { - try { - nativeMergeAbort(cwd); - cleaned.push("aborted merge"); - } catch { - // merge --abort can fail if state is really broken; continue to reset - cleaned.push("merge abort attempted (may have failed)"); - } - } - - // Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD) - const squashMsgPath = join(gitDir, "SQUASH_MSG"); - if (existsSync(squashMsgPath)) { - try { - unlinkSync(squashMsgPath); - cleaned.push("removed SQUASH_MSG"); - } catch { - // Not critical - } - } - - // Abort in-progress rebase - if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) { - try { - nativeRebaseAbort(cwd); - cleaned.push("aborted rebase"); - } catch { - cleaned.push("rebase abort attempted (may have failed)"); - } - } - - // Always hard-reset to HEAD - try { - nativeResetHard(cwd); - if (cleaned.length > 0) { - cleaned.push("reset to HEAD"); - } - } catch { - cleaned.push("reset to HEAD failed"); - } - - return { cleaned }; -} - -/** Known git error patterns mapped to user-friendly messages. */ -const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [ - { - pattern: /conflict|CONFLICT|merge conflict/i, - message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.", - }, - { - pattern: /cannot checkout|did not match any|pathspec .* did not match/i, - message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.", - }, - { - pattern: /HEAD detached|detached HEAD/i, - message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.", - }, - { - pattern: /\.lock|Unable to create .* lock|lock file/i, - message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.", - }, - { - pattern: /fatal: not a git repository/i, - message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.", - }, -]; - -/** - * Translate raw git error strings into user-friendly messages. - * - * Pattern-matches against common git error strings and returns - * a non-technical message suggesting `/gsd doctor`. Returns the - * original message if no pattern matches. - */ -export function formatGitError(error: string | Error): string { - const errorStr = error instanceof Error ? error.message : error; - - for (const { pattern, message } of ERROR_PATTERNS) { - if (pattern.test(errorStr)) { - return message; - } - } - - return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`; -} diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts deleted file mode 100644 index b8175c1ed..000000000 --- a/src/resources/extensions/gsd/git-service.ts +++ /dev/null @@ -1,919 +0,0 @@ -/** - * SF Git Service - * - * Core git operations for SF: types, constants, and pure helpers. - * Higher-level operations (commit, staging, branching) build on these. - * - * This module centralizes the GitPreferences interface, runtime exclusion - * paths, commit type inference, and the runGit shell helper. - */ - -import { execFileSync, execSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { gsdRoot } from "./paths.js"; -import { GIT_NO_PROMPT_ENV } from "./git-constants.js"; -import { loadEffectiveGSDPreferences } from "./preferences.js"; - - -import { - detectWorktreeName, -} from "./worktree.js"; -import { SLICE_BRANCH_RE, QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js"; -import { - nativeGetCurrentBranch, - nativeDetectMainBranch, - nativeBranchExists, - nativeHasChanges, - nativeAddAllWithExclusions, - nativeResetPaths, - nativeHasStagedChanges, - nativeCommit, - nativeRmCached, - nativeUpdateRef, - nativeAddPaths, - nativeResetSoft, - nativeCommitSubject, - _resetHasChangesCache, -} from "./native-git-bridge.js"; -import { GSDError, SF_MERGE_CONFLICT, SF_GIT_ERROR } from "./errors.js"; -import { getErrorMessage } from "./error-utils.js"; - -// ─── Types ───────────────────────────────────────────────────────────────── - -export interface GitPreferences { - auto_push?: boolean; - push_branches?: boolean; - remote?: string; - snapshots?: boolean; - /** Deprecated. .gsd/ is managed externally; retained for compatibility. */ - commit_docs?: boolean; - pre_merge_check?: boolean | string; - commit_type?: string; - main_branch?: string; - merge_strategy?: "squash" | "merge"; - /** Controls auto-mode git isolation strategy. - * - "worktree": creates a milestone worktree for isolated work - * - "branch": works directly in the project root (for submodule-heavy repos) - * - "none": (default) no git isolation — commits land on the user's current branch directly - */ - isolation?: "worktree" | "branch" | "none"; - /** When false, SF will not modify .gitignore at all — no baseline patterns - * are added and no self-healing occurs. Use this if you manage your own - * .gitignore and don't want SF touching it. - * Default: true (SF ensures baseline patterns are present). - */ - manage_gitignore?: boolean; - /** Script to run after a worktree is created (#597). - * Receives SOURCE_DIR and WORKTREE_DIR as environment variables. - * Can be an absolute path or relative to the project root. - * Failure is non-fatal — logged as a warning. - */ - worktree_post_create?: string; - /** When true, automatically create a pull request after milestone completion. - * The PR targets `pr_target_branch` (default: the main branch). - * Requires `push_branches: true` and a configured remote. - * Default: false. - */ - auto_pr?: boolean; - /** Target branch for auto-created PRs (e.g. "develop", "qa"). - * Default: the main branch (from `main_branch` or auto-detected). - */ - pr_target_branch?: string; - /** Whether to squash `gsd snapshot:` commits into the next real autoCommit. - * Enabled by default. Set to false to keep snapshot commits in history - * for forensic inspection. - */ - absorb_snapshot_commits?: boolean; -} - -export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; - -export interface CommitOptions { - message: string; - allowEmpty?: boolean; -} - -export type TurnGitActionMode = "commit" | "snapshot" | "status-only"; - -export interface TurnGitActionResult { - action: TurnGitActionMode; - status: "ok" | "failed"; - commitMessage?: string; - snapshotLabel?: string; - dirty?: boolean; - error?: string; -} - -// ─── Meaningful Commit Message Generation ─────────────────────────────────── - -/** Context for generating a meaningful commit message from task execution results. */ -export interface TaskCommitContext { - taskId: string; - taskTitle: string; - /** The one-liner from the task summary (e.g. "Added retry-aware worker status logging") */ - oneLiner?: string; - /** Files modified by this task (from task summary frontmatter) */ - keyFiles?: string[]; - /** GitHub issue number — appends "Resolves #N" trailer when set. */ - issueNumber?: number; -} - -/** - * Build a meaningful conventional commit message from task execution context. - * Format: `{type}: {description}` (clean conventional commit — no SF IDs in subject). - * - * SF metadata is placed in a `SF-Task:` git trailer at the end of the body, - * following the same convention as `Signed-off-by:` or `Co-Authored-By:`. - * - * The description is the task summary one-liner if available (it describes - * what was actually built), falling back to the task title (what was planned). - */ -export function buildTaskCommitMessage(ctx: TaskCommitContext): string { - const description = ctx.oneLiner || ctx.taskTitle; - const type = inferCommitType(ctx.taskTitle, ctx.oneLiner); - - // Truncate description to ~72 chars for subject line (full budget without scope) - const maxDescLen = 70 - type.length; - const truncated = description.length > maxDescLen - ? description.slice(0, maxDescLen - 1).trimEnd() + "…" - : description; - - const subject = `${type}: ${truncated}`; - - // Build body with key files if available - const bodyParts: string[] = []; - - if (ctx.keyFiles && ctx.keyFiles.length > 0) { - const fileLines = ctx.keyFiles - .slice(0, 8) // cap at 8 files to keep commit concise - .map(f => `- ${f}`) - .join("\n"); - bodyParts.push(fileLines); - } - - // Trailers: SF-Task first, then Resolves - bodyParts.push(`SF-Task: ${ctx.taskId}`); - - if (ctx.issueNumber) { - bodyParts.push(`Resolves #${ctx.issueNumber}`); - } - - return `${subject}\n\n${bodyParts.join("\n\n")}`; -} - -/** - * Thrown when a slice merge hits code conflicts in non-.gsd files. - * The working tree is left in a conflicted state (no reset) so the - * caller can dispatch a fix-merge session to resolve it. - */ -export class MergeConflictError extends GSDError { - readonly conflictedFiles: string[]; - readonly strategy: "squash" | "merge"; - readonly branch: string; - readonly mainBranch: string; - - constructor( - conflictedFiles: string[], - strategy: "squash" | "merge", - branch: string, - mainBranch: string, - ) { - super( - SF_MERGE_CONFLICT, - `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" ` + - `failed with conflicts in ${conflictedFiles.length} non-.gsd file(s): ${conflictedFiles.join(", ")}`, - ); - this.name = "MergeConflictError"; - this.conflictedFiles = conflictedFiles; - this.strategy = strategy; - this.branch = branch; - this.mainBranch = mainBranch; - } -} - -export interface PreMergeCheckResult { - passed: boolean; - skipped?: boolean; - command?: string; - error?: string; -} - -// ─── Constants ───────────────────────────────────────────────────────────── - -/** - * SF runtime paths that should be excluded from smart staging. - * These are transient/generated artifacts that should never be committed. - * - * NOTE: SF_RUNTIME_PATTERNS in gitignore.ts is the canonical source of truth. - * This array must stay synchronized with it. - */ -export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [ - ".gsd/activity/", - ".gsd/forensics/", - ".gsd/runtime/", - ".gsd/worktrees/", - ".gsd/parallel/", - ".gsd/auto.lock", - ".gsd/metrics.json", - ".gsd/completed-units*.json", // covers completed-units.json and archived completed-units-{MID}.json - ".gsd/state-manifest.json", - ".gsd/STATE.md", - ".gsd/gsd.db*", - ".gsd/journal/", - ".gsd/doctor-history.jsonl", - ".gsd/event-log.jsonl", - ".gsd/DISCUSSION-MANIFEST.json", -]; - -// ─── Integration Branch Metadata ─────────────────────────────────────────── - -/** - * Path to the milestone metadata file that stores the integration branch. - * Format: .gsd/milestones//-META.json - */ -function milestoneMetaPath(basePath: string, milestoneId: string): string { - return join(gsdRoot(basePath), "milestones", milestoneId, `${milestoneId}-META.json`); -} - -/** - * Read the integration branch recorded for a milestone. - * Returns null if no metadata file exists or the branch isn't set. - */ -export function readIntegrationBranch(basePath: string, milestoneId: string): string | null { - try { - const metaFile = milestoneMetaPath(basePath, milestoneId); - if (!existsSync(metaFile)) return null; - const data = JSON.parse(readFileSync(metaFile, "utf-8")); - const branch = data?.integrationBranch; - if (typeof branch === "string" && branch.trim() !== "" && VALID_BRANCH_NAME.test(branch)) { - return branch; - } - return null; - } catch { - return null; - } -} - -/** - * Persist the integration branch for a milestone. - * - * Called when auto-mode starts on a milestone. Records the branch the user - * was on at that point, so the milestone worktree merges back to the correct - * branch. Idempotent when the branch matches; updates the record when the - * user starts from a different branch. - * - * The file is committed immediately so the metadata is persisted in git. - */ -/** Re-export for backward compatibility — canonical definitions in branch-patterns.ts */ -export { QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js"; - -export function writeIntegrationBranch( - basePath: string, - milestoneId: string, - branch: string, -): void { - // Don't record slice branches as the integration target - if (SLICE_BRANCH_RE.test(branch)) return; - // Don't record quick-task branches — they are ephemeral and merge back - // to their origin branch on completion. Recording one as the integration - // target causes milestone merges to land on the wrong branch (#1293). - if (QUICK_BRANCH_RE.test(branch)) return; - // Don't record workflow-template branches (hotfix, bugfix, spike, etc.) — - // same root cause as quick-task branches (#2498). All templates create - // gsd// branches that are ephemeral. - if (WORKFLOW_BRANCH_RE.test(branch)) return; - // Validate - if (!VALID_BRANCH_NAME.test(branch)) return; - // Skip if already recorded with the same branch (idempotent across restarts). - // If recorded with a different branch, update it — the user started auto-mode - // from a new branch and expects slices to merge back there (#300). - const existingBranch = readIntegrationBranch(basePath, milestoneId); - if (existingBranch === branch) return; - - const metaFile = milestoneMetaPath(basePath, milestoneId); - mkdirSync(join(gsdRoot(basePath), "milestones", milestoneId), { recursive: true }); - - // Merge with existing metadata if present - let existing: Record = {}; - try { - if (existsSync(metaFile)) { - existing = JSON.parse(readFileSync(metaFile, "utf-8")); - } - } catch { /* corrupt file — overwrite */ } - - existing.integrationBranch = branch; - writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8"); - // .gsd/ is managed externally (symlinked) — metadata is not committed to git. -} - -export type IntegrationBranchResolutionStatus = "recorded" | "fallback" | "missing"; - -export interface IntegrationBranchResolution { - recordedBranch: string | null; - effectiveBranch: string | null; - status: IntegrationBranchResolutionStatus; - reason: string; -} - -/** - * Resolve a milestone's recorded integration branch into an actionable status. - * - * This helper is intentionally scoped to milestones that already have recorded - * metadata. If no integration branch is recorded, it returns `missing` with no - * effective branch so callers can continue with their existing non-milestone - * fallback logic (for example worktree/current-branch detection in getMainBranch). - */ -export function resolveMilestoneIntegrationBranch( - basePath: string, - milestoneId: string, - prefs: GitPreferences = {}, -): IntegrationBranchResolution { - const recordedBranch = readIntegrationBranch(basePath, milestoneId); - if (!recordedBranch) { - return { - recordedBranch: null, - effectiveBranch: null, - status: "missing", - reason: `Milestone ${milestoneId} has no recorded integration branch metadata.`, - }; - } - - if (nativeBranchExists(basePath, recordedBranch)) { - return { - recordedBranch, - effectiveBranch: recordedBranch, - status: "recorded", - reason: `Using recorded integration branch "${recordedBranch}" for milestone ${milestoneId}.`, - }; - } - - const configuredBranch = prefs.main_branch && VALID_BRANCH_NAME.test(prefs.main_branch) - ? prefs.main_branch - : null; - - if (configuredBranch) { - if (nativeBranchExists(basePath, configuredBranch)) { - return { - recordedBranch, - effectiveBranch: configuredBranch, - status: "fallback", - reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using configured git.main_branch "${configuredBranch}" instead.`, - }; - } - - return { - recordedBranch, - effectiveBranch: null, - status: "missing", - reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and configured git.main_branch "${configuredBranch}" is unavailable.`, - }; - } - - try { - const detectedBranch = nativeDetectMainBranch(basePath); - if (detectedBranch && VALID_BRANCH_NAME.test(detectedBranch) && nativeBranchExists(basePath, detectedBranch)) { - return { - recordedBranch, - effectiveBranch: detectedBranch, - status: "fallback", - reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists; using detected fallback branch "${detectedBranch}" instead.`, - }; - } - } catch { - // Fall through to the explicit missing result below. - } - - return { - recordedBranch, - effectiveBranch: null, - status: "missing", - reason: `Recorded integration branch "${recordedBranch}" for milestone ${milestoneId} no longer exists, and no safe fallback branch could be determined.`, - }; -} - -// ─── Git Helper ──────────────────────────────────────────────────────────── - - -/** - * Strip git-svn noise from error messages. - * Some systems (notably Arch Linux) have a buggy git-svn Perl module that - * emits warnings on every git invocation, confusing users. See #404. - */ -function filterGitSvnNoise(message: string): string { - return message - .replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "") - .replace(/Unable to determine upstream SVN information from .*\n?/g, "") - .replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "") - .trim(); -} - -/** - * Run a git command in the given directory. - * Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set. - * When `input` is provided, it is piped to stdin. - */ -export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string { - try { - return execFileSync("git", args, { - cwd: basePath, - stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"], - encoding: "utf-8", - env: GIT_NO_PROMPT_ENV, - ...(options.input != null ? { input: options.input } : {}), - }).trim(); - } catch (error) { - if (options.allowFailure) return ""; - const message = getErrorMessage(error); - throw new GSDError(SF_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`); - } -} - -// ─── Commit Type Inference ───────────────────────────────────────────────── - -/** - * Keyword-to-commit-type mapping. Order matters — first match wins. - * Each entry: [keywords[], commitType] - */ -const COMMIT_TYPE_RULES: [string[], string][] = [ - [["fix", "fixed", "fixes", "bug", "patch", "hotfix", "repair", "correct"], "fix"], - [["refactor", "restructure", "reorganize"], "refactor"], - [["doc", "docs", "documentation", "readme", "changelog"], "docs"], - [["test", "tests", "testing", "spec", "coverage"], "test"], - [["perf", "performance", "optimize", "speed", "cache"], "perf"], - [["chore", "cleanup", "clean up", "dependencies", "deps", "bump", "config", "ci", "archive", "remove", "delete"], "chore"], -]; - -// ─── GitServiceImpl ──────────────────────────────────────────────────── - -export class GitServiceImpl { - readonly basePath: string; - readonly prefs: GitPreferences; - - /** Active milestone ID — used to resolve the integration branch. */ - private _milestoneId: string | null = null; - - constructor(basePath: string, prefs: GitPreferences = {}) { - this.basePath = basePath; - this.prefs = prefs; - } - - /** - * Set the active milestone ID for integration branch resolution. - * When set, getMainBranch() will check the milestone's metadata file - * for a recorded integration branch before falling back to repo defaults. - */ - setMilestoneId(milestoneId: string | null): void { - this._milestoneId = milestoneId; - } - - /** - * Smart staging: `git add -A` excluding SF runtime paths via pathspec. - * Falls back to plain `git add -A` if the exclusion pathspec fails. - * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS. - */ - private smartStage(extraExclusions: readonly string[] = []): void { - // One-time cleanup: if runtime files are already tracked in the index - // (from older versions where the fallback bug staged them), untrack them - // in a dedicated commit. This must happen as a separate commit because - // the git reset HEAD step below would otherwise undo the rm --cached. - // - // SAFETY: Only untrack the specific RUNTIME paths (activity/, runtime/, - // auto.lock, etc.) — NOT all of .gsd/. If .gsd/milestones/ files were - // previously tracked, they stay tracked until the milestone completes - // and the worktree is torn down. This prevents a mid-execution behavioral - // discontinuity where the first half of a milestone has .gsd/ artifacts - // committed but the second half doesn't (#1326). - if (!this._runtimeFilesCleanedUp) { - let cleaned = false; - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - const removed = nativeRmCached(this.basePath, [exclusion]); - if (removed.length > 0) cleaned = true; - } - if (cleaned) { - nativeCommit(this.basePath, "chore: untrack .gsd/ runtime files from git index", { allowEmpty: false }); - } - this._runtimeFilesCleanedUp = true; - } - - // Stage everything using pathspec exclusions so excluded paths are never - // hashed by git. The old approach of `git add -A` followed by unstaging - // hangs indefinitely on repos with large untracked artifact trees (#1605). - // - // Exclude only RUNTIME paths from staging — not the entire .gsd/ directory. - // When .gsd/milestones/ files are already tracked in the index (projects - // where .gsd/ is not gitignored, or Windows junctions that git sees as - // real directories), they should continue to be committed. Excluding the - // entire .gsd/ directory mid-milestone causes silent commit failure where - // the second half of a milestone's artifacts are never committed (#1326). - // - // If .gsd/ IS in .gitignore (the default for external state projects), - // git add -A already skips it and the exclusions are harmless no-ops. - const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; - - // ── Parallel worker milestone scope (#1991) ── - // When SF_MILESTONE_LOCK is set, this process is a parallel worker that - // must only commit files belonging to its own milestone. Exclude all other - // milestone directories from staging to prevent cross-milestone pollution - // (e.g., an M033 worker fabricating M032 artifacts in the same commit). - const milestoneLock = process.env.SF_MILESTONE_LOCK; - if (milestoneLock) { - const msDir = join(gsdRoot(this.basePath), "milestones"); - if (existsSync(msDir)) { - try { - const entries = readdirSync(msDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== milestoneLock) { - allExclusions.push(`.gsd/milestones/${entry.name}/`); - } - } - } catch { - // Best-effort — if we can't read the milestones dir, proceed without scoping - } - } - } - - nativeAddAllWithExclusions(this.basePath, allExclusions); - } - - /** Tracks whether runtime file cleanup has run this session. */ - private _runtimeFilesCleanedUp = false; - - /** - * Stage files (smart staging) and commit. - * Returns the commit message string on success, or null if nothing to commit. - * Uses `git commit -F -` with stdin pipe for safe multi-line message handling. - */ - commit(opts: CommitOptions): string | null { - this.smartStage(); - - // Check if anything was actually staged - if (!nativeHasStagedChanges(this.basePath) && !opts.allowEmpty) return null; - - nativeCommit(this.basePath, opts.message, { allowEmpty: opts.allowEmpty ?? false }); - return opts.message; - } - - /** - * Auto-commit dirty working tree. - * - * When `taskContext` is provided, generates a meaningful conventional commit - * message from the task execution results (one-liner, title, inferred type). - * Falls back to a generic `chore()` message when no context is available - * (e.g. pre-switch commits, stop commits, state rebuild commits). - * - * Returns the commit message on success, or null if nothing to commit. - * @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits). - */ - autoCommit( - unitType: string, - unitId: string, - extraExclusions: readonly string[] = [], - taskContext?: TaskCommitContext, - ): string | null { - // Quick check: is there anything dirty at all? - // Native path uses libgit2 (single syscall), fallback spawns git. - if (!nativeHasChanges(this.basePath)) return null; - - this.smartStage(extraExclusions); - - // After smart staging, check if anything was actually staged - // (all changes might have been runtime files that got excluded) - if (!nativeHasStagedChanges(this.basePath)) return null; - - const message = taskContext - ? buildTaskCommitMessage(taskContext) - : `chore: auto-commit after ${unitType}\n\nGSD-Unit: ${unitId}`; - nativeCommit(this.basePath, message, { allowEmpty: false }); - - // Absorb any preceding gsd snapshot commits into this real commit. - // Walk backwards from HEAD~1 counting consecutive snapshot subjects, - // then soft-reset to before them and re-commit with the same message. - this.absorbSnapshotCommits(message); - - return message; - } - - /** - * Squash consecutive `gsd snapshot:` commits that sit immediately below - * HEAD into the current HEAD commit. This keeps the git history clean - * after automated snapshot commits are superseded by real work. - * - * Guards: - * - Opt-in via `absorb_snapshot_commits` preference (default: true). - * - Refuses to rewrite commits that have been pushed to the remote - * tracking branch (checks merge-base ancestry). - * - Saves HEAD SHA before reset; restores it if the re-commit fails. - * - * Does nothing if there are no snapshot commits to absorb. - */ - private absorbSnapshotCommits(headMessage: string): void { - try { - // Opt-in guard — users can disable to keep snapshot commits for forensics - if (this.prefs.absorb_snapshot_commits === false) return; - - const SF_SNAPSHOT_PREFIX = "gsd snapshot:"; - let count = 0; - - // Walk back from HEAD~1 counting consecutive snapshot commits (cap at 10) - for (let i = 1; i <= 10; i++) { - const subject = nativeCommitSubject(this.basePath, `HEAD~${i}`); - if (!subject.startsWith(SF_SNAPSHOT_PREFIX)) break; - count = i; - } - - if (count === 0) return; - - // Guard: don't rewrite history that has been pushed to the remote. - // Check whether the newest snapshot commit (HEAD~1) is already - // reachable from the remote tracking branch. If it is, the snapshots - // have been pushed and must not be squashed via local history rewrite. - // (Checking resetTarget instead would false-positive when the remote - // is at the pre-snapshot base but the snapshots themselves are local.) - const resetTarget = `HEAD~${count + 1}`; - try { - const branch = nativeGetCurrentBranch(this.basePath); - if (branch) { - const remoteBranch = `origin/${branch}`; - // merge-base --is-ancestor exits 0 if HEAD~1 is ancestor of remote - execFileSync("git", ["merge-base", "--is-ancestor", "HEAD~1", remoteBranch], { - cwd: this.basePath, - stdio: ["ignore", "pipe", "pipe"], - }); - // If we get here, newest snapshot IS reachable from remote — already pushed - return; - } - } catch { - // Not an ancestor or remote doesn't exist — safe to proceed - } - - // Save HEAD SHA so we can restore if the re-commit fails - const savedHead = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: this.basePath, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }).trim(); - - nativeResetSoft(this.basePath, resetTarget); - - // Re-run smartStage so the same RUNTIME_EXCLUSION_PATHS apply. - // Snapshot commits used nativeAddTracked (git add -u) which stages - // ALL tracked modifications including .gsd/ state files. Without - // re-staging, those .gsd/ changes leak into the absorbed commit. - this.smartStage(); - - try { - nativeCommit(this.basePath, headMessage, { allowEmpty: false }); - } catch { - // Re-commit failed — restore original HEAD to avoid leaving the - // repo in a partially-reset state with no commit - nativeResetSoft(this.basePath, savedHead); - } - } catch { - // Non-fatal — if squash fails, the commits remain unsquashed - } - } - - // ─── Branch Queries ──────────────────────────────────────────────────── - - /** - * Get the integration branch for this repo — the branch that slice - * branches are created from and merged back into. - * - * This is often `main` or `master`, but not necessarily. When a user - * starts SF on a feature branch like `f-123-new-thing`, that branch - * is recorded as the integration target, and all slice branches merge - * back into it — not the repo's default branch. The name "main branch" - * in variable names is historical; think of it as "integration branch". - * - * Resolution order: - * 1. Explicit `main_branch` preference (user override, highest priority) - * 2. Milestone integration branch from metadata file (recorded at milestone start) - * 3. Worktree base branch (worktree/) - * 4. origin/HEAD symbolic-ref → main/master fallback → current branch - */ - getMainBranch(): string { - // Explicit preference takes priority (double-check validity as defense-in-depth) - if (this.prefs.main_branch && VALID_BRANCH_NAME.test(this.prefs.main_branch)) { - return this.prefs.main_branch; - } - - // Check milestone integration branch — recorded when auto-mode starts - if (this._milestoneId) { - const resolved = resolveMilestoneIntegrationBranch(this.basePath, this._milestoneId); - if (resolved.effectiveBranch) { - return resolved.effectiveBranch; - } - } - - const wtName = detectWorktreeName(this.basePath); - if (wtName) { - // Auto-mode worktrees use milestone/ branches (wtName = milestone ID) - const milestoneBranch = `milestone/${wtName}`; - const currentBranch = nativeGetCurrentBranch(this.basePath); - - // If we're on a milestone/ branch, use it (auto-mode case) - if (currentBranch.startsWith("milestone/")) { - return currentBranch; - } - - // Otherwise check for manual worktree branch (worktree/) - const wtBranch = `worktree/${wtName}`; - if (nativeBranchExists(this.basePath, wtBranch)) return wtBranch; - - return currentBranch; - } - - // Repo-level default detection: origin/HEAD → main → master → current branch. - // Native path uses libgit2 (single call), fallback spawns multiple git processes. - return nativeDetectMainBranch(this.basePath); - } - - /** Get the current branch name. Native libgit2 when available, execSync fallback. */ - getCurrentBranch(): string { - return nativeGetCurrentBranch(this.basePath); - } - - /** - * Create a snapshot ref for the given label (typically a slice branch name). - * Enabled by default; opt out with prefs.snapshots === false. - * Ref path: refs/gsd/snapshots/