gsd-pi 2.10.12 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundled-extension-paths.d.ts +2 -0
- package/dist/bundled-extension-paths.js +10 -0
- package/dist/loader.js +45 -28
- package/dist/onboarding.js +100 -0
- package/dist/resource-loader.js +12 -3
- package/dist/resources/GSD-WORKFLOW.md +661 -0
- package/dist/resources/agents/researcher.md +29 -0
- package/dist/resources/agents/scout.md +56 -0
- package/dist/resources/agents/worker.md +31 -0
- package/dist/resources/extensions/ask-user-questions.ts +289 -0
- package/dist/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
- package/dist/resources/extensions/async-jobs/await-tool.ts +101 -0
- package/dist/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
- package/dist/resources/extensions/async-jobs/index.ts +133 -0
- package/dist/resources/extensions/async-jobs/job-manager.ts +211 -0
- package/dist/resources/extensions/bg-shell/index.ts +3179 -0
- package/dist/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/dist/resources/extensions/browser-tools/capture.ts +165 -0
- package/dist/resources/extensions/browser-tools/core.d.ts +205 -0
- package/dist/resources/extensions/browser-tools/core.js +1057 -0
- package/dist/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
- package/dist/resources/extensions/browser-tools/index.ts +51 -0
- package/dist/resources/extensions/browser-tools/lifecycle.ts +270 -0
- package/dist/resources/extensions/browser-tools/package.json +24 -0
- package/dist/resources/extensions/browser-tools/refs.ts +264 -0
- package/dist/resources/extensions/browser-tools/settle.ts +197 -0
- package/dist/resources/extensions/browser-tools/state.ts +408 -0
- package/dist/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
- package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
- package/dist/resources/extensions/browser-tools/tools/assertions.ts +342 -0
- package/dist/resources/extensions/browser-tools/tools/forms.ts +801 -0
- package/dist/resources/extensions/browser-tools/tools/inspection.ts +492 -0
- package/dist/resources/extensions/browser-tools/tools/intent.ts +614 -0
- package/dist/resources/extensions/browser-tools/tools/interaction.ts +865 -0
- package/dist/resources/extensions/browser-tools/tools/navigation.ts +232 -0
- package/dist/resources/extensions/browser-tools/tools/pages.ts +303 -0
- package/dist/resources/extensions/browser-tools/tools/refs.ts +541 -0
- package/dist/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
- package/dist/resources/extensions/browser-tools/tools/session.ts +400 -0
- package/dist/resources/extensions/browser-tools/tools/wait.ts +247 -0
- package/dist/resources/extensions/browser-tools/utils.ts +660 -0
- package/dist/resources/extensions/context7/index.ts +428 -0
- package/dist/resources/extensions/context7/package.json +11 -0
- package/dist/resources/extensions/get-secrets-from-user.ts +625 -0
- package/dist/resources/extensions/google-search/index.ts +336 -0
- package/dist/resources/extensions/google-search/package.json +9 -0
- package/dist/resources/extensions/gsd/activity-log.ts +116 -0
- package/dist/resources/extensions/gsd/auto.ts +3732 -0
- package/dist/resources/extensions/gsd/commands.ts +544 -0
- package/dist/resources/extensions/gsd/crash-recovery.ts +105 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +564 -0
- package/dist/resources/extensions/gsd/dispatch-guard.ts +78 -0
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +278 -0
- package/dist/resources/extensions/gsd/doctor.ts +766 -0
- package/dist/resources/extensions/gsd/exit-command.ts +18 -0
- package/dist/resources/extensions/gsd/files.ts +857 -0
- package/dist/resources/extensions/gsd/git-service.ts +904 -0
- package/dist/resources/extensions/gsd/gitignore.ts +200 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +1054 -0
- package/dist/resources/extensions/gsd/index.ts +735 -0
- package/dist/resources/extensions/gsd/metrics.ts +374 -0
- package/dist/resources/extensions/gsd/migrate/command.ts +218 -0
- package/dist/resources/extensions/gsd/migrate/index.ts +42 -0
- package/dist/resources/extensions/gsd/migrate/parser.ts +323 -0
- package/dist/resources/extensions/gsd/migrate/parsers.ts +624 -0
- package/dist/resources/extensions/gsd/migrate/preview.ts +48 -0
- package/dist/resources/extensions/gsd/migrate/transformer.ts +346 -0
- package/dist/resources/extensions/gsd/migrate/types.ts +370 -0
- package/dist/resources/extensions/gsd/migrate/validator.ts +55 -0
- package/dist/resources/extensions/gsd/migrate/writer.ts +539 -0
- package/dist/resources/extensions/gsd/native-git-bridge.ts +181 -0
- package/dist/resources/extensions/gsd/native-parser-bridge.ts +135 -0
- package/dist/resources/extensions/gsd/observability-validator.ts +408 -0
- package/dist/resources/extensions/gsd/package.json +11 -0
- package/dist/resources/extensions/gsd/paths.ts +339 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/dist/resources/extensions/gsd/preferences.ts +988 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +92 -0
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +27 -0
- package/dist/resources/extensions/gsd/prompts/discuss.md +237 -0
- package/dist/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
- package/dist/resources/extensions/gsd/prompts/execute-task.md +63 -0
- package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +5 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +61 -0
- package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -0
- package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +29 -0
- package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -0
- package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +13 -0
- package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +78 -0
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +55 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +100 -0
- package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
- package/dist/resources/extensions/gsd/prompts/replan-slice.md +39 -0
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +37 -0
- package/dist/resources/extensions/gsd/prompts/research-slice.md +28 -0
- package/dist/resources/extensions/gsd/prompts/review-migration.md +66 -0
- package/dist/resources/extensions/gsd/prompts/run-uat.md +109 -0
- package/dist/resources/extensions/gsd/prompts/system.md +190 -0
- package/dist/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
- package/dist/resources/extensions/gsd/roadmap-slices.ts +50 -0
- package/dist/resources/extensions/gsd/session-forensics.ts +500 -0
- package/dist/resources/extensions/gsd/skill-discovery.ts +137 -0
- package/dist/resources/extensions/gsd/state.ts +569 -0
- package/dist/resources/extensions/gsd/templates/context.md +76 -0
- package/dist/resources/extensions/gsd/templates/decisions.md +8 -0
- package/dist/resources/extensions/gsd/templates/milestone-summary.md +73 -0
- package/dist/resources/extensions/gsd/templates/plan.md +131 -0
- package/dist/resources/extensions/gsd/templates/preferences.md +23 -0
- package/dist/resources/extensions/gsd/templates/project.md +31 -0
- package/dist/resources/extensions/gsd/templates/reassessment.md +28 -0
- package/dist/resources/extensions/gsd/templates/requirements.md +81 -0
- package/dist/resources/extensions/gsd/templates/research.md +46 -0
- package/dist/resources/extensions/gsd/templates/roadmap.md +118 -0
- package/dist/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
- package/dist/resources/extensions/gsd/templates/slice-context.md +58 -0
- package/dist/resources/extensions/gsd/templates/slice-summary.md +99 -0
- package/dist/resources/extensions/gsd/templates/state.md +19 -0
- package/dist/resources/extensions/gsd/templates/task-plan.md +52 -0
- package/dist/resources/extensions/gsd/templates/task-summary.md +57 -0
- package/dist/resources/extensions/gsd/templates/uat.md +54 -0
- package/dist/resources/extensions/gsd/tests/activity-log-prune.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/activity-log-save.test.ts +127 -0
- package/dist/resources/extensions/gsd/tests/auto-draft-pause.test.ts +109 -0
- package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +45 -0
- package/dist/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
- package/dist/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
- package/dist/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
- package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +200 -0
- package/dist/resources/extensions/gsd/tests/cost-projection.test.ts +134 -0
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +312 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +299 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +660 -0
- package/dist/resources/extensions/gsd/tests/discuss-prompt.test.ts +27 -0
- package/dist/resources/extensions/gsd/tests/dispatch-guard.test.ts +91 -0
- package/dist/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +594 -0
- package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +169 -0
- package/dist/resources/extensions/gsd/tests/exit-command.test.ts +50 -0
- package/dist/resources/extensions/gsd/tests/git-service.test.ts +1788 -0
- package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +577 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +580 -0
- package/dist/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/dist/resources/extensions/gsd/tests/metrics-io.test.ts +176 -0
- package/dist/resources/extensions/gsd/tests/metrics.test.ts +188 -0
- package/dist/resources/extensions/gsd/tests/migrate-command.test.ts +369 -0
- package/dist/resources/extensions/gsd/tests/migrate-parser.test.ts +757 -0
- package/dist/resources/extensions/gsd/tests/migrate-transformer.test.ts +635 -0
- package/dist/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +414 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer.test.ts +398 -0
- package/dist/resources/extensions/gsd/tests/must-have-parser.test.ts +291 -0
- package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +54 -0
- package/dist/resources/extensions/gsd/tests/next-milestone-id.test.ts +66 -0
- package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +353 -0
- package/dist/resources/extensions/gsd/tests/parsers.test.ts +1664 -0
- package/dist/resources/extensions/gsd/tests/plan-milestone.test.ts +133 -0
- package/dist/resources/extensions/gsd/tests/plan-quality-validator.test.ts +363 -0
- package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/dist/resources/extensions/gsd/tests/queue-draft-detection.test.ts +126 -0
- package/dist/resources/extensions/gsd/tests/reassess-prompt.test.ts +142 -0
- package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +281 -0
- package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
- package/dist/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
- package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +493 -0
- package/dist/resources/extensions/gsd/tests/requirements.test.ts +106 -0
- package/dist/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +35 -0
- package/dist/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
- package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +41 -0
- package/dist/resources/extensions/gsd/tests/run-uat.test.ts +316 -0
- package/dist/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/dist/resources/extensions/gsd/tests/smart-entry-draft.test.ts +123 -0
- package/dist/resources/extensions/gsd/tests/test-helpers.ts +61 -0
- package/dist/resources/extensions/gsd/tests/unique-milestone-ids.test.ts +219 -0
- package/dist/resources/extensions/gsd/tests/unit-runtime.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/workspace-index.test.ts +75 -0
- package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/worktree-manager.test.ts +141 -0
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +597 -0
- package/dist/resources/extensions/gsd/tests/write-gate.test.ts +122 -0
- package/dist/resources/extensions/gsd/types.ts +296 -0
- package/dist/resources/extensions/gsd/unit-runtime.ts +184 -0
- package/dist/resources/extensions/gsd/workspace-index.ts +189 -0
- package/dist/resources/extensions/gsd/worktree-command.ts +845 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +459 -0
- package/dist/resources/extensions/gsd/{worktree.js → worktree.ts} +92 -61
- package/dist/resources/extensions/mac-tools/index.ts +852 -0
- package/dist/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/dist/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/dist/resources/extensions/mcporter/index.ts +429 -0
- package/dist/resources/extensions/remote-questions/config.ts +81 -0
- package/dist/resources/extensions/remote-questions/discord-adapter.ts +128 -0
- package/dist/resources/extensions/remote-questions/format.ts +163 -0
- package/dist/resources/extensions/remote-questions/manager.ts +192 -0
- package/dist/resources/extensions/remote-questions/remote-command.ts +361 -0
- package/dist/resources/extensions/remote-questions/slack-adapter.ts +92 -0
- package/dist/resources/extensions/remote-questions/status.ts +31 -0
- package/dist/resources/extensions/remote-questions/store.ts +77 -0
- package/dist/resources/extensions/remote-questions/types.ts +75 -0
- package/dist/resources/extensions/search-the-web/cache.ts +78 -0
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +99 -0
- package/dist/resources/extensions/search-the-web/format.ts +258 -0
- package/dist/resources/extensions/search-the-web/http.ts +238 -0
- package/dist/resources/extensions/search-the-web/index.ts +65 -0
- package/dist/resources/extensions/search-the-web/native-search.ts +165 -0
- package/dist/resources/extensions/search-the-web/provider.ts +136 -0
- package/dist/resources/extensions/search-the-web/tavily.ts +116 -0
- package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +581 -0
- package/dist/resources/extensions/search-the-web/tool-llm-context.ts +620 -0
- package/dist/resources/extensions/search-the-web/tool-search.ts +635 -0
- package/dist/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/dist/resources/extensions/shared/bundled-extension-paths.ts +11 -0
- package/dist/resources/extensions/shared/confirm-ui.ts +126 -0
- package/dist/resources/extensions/shared/interview-ui.ts +613 -0
- package/dist/resources/extensions/shared/next-action-ui.ts +197 -0
- package/dist/resources/extensions/shared/progress-widget.ts +282 -0
- package/dist/resources/extensions/shared/terminal.ts +23 -0
- package/dist/resources/extensions/shared/thinking-widget.ts +107 -0
- package/dist/resources/extensions/shared/ui.ts +400 -0
- package/dist/resources/extensions/shared/wizard-ui.ts +551 -0
- package/dist/resources/extensions/slash-commands/audit.ts +88 -0
- package/dist/resources/extensions/slash-commands/clear.ts +10 -0
- package/dist/resources/extensions/slash-commands/create-extension.ts +297 -0
- package/dist/resources/extensions/slash-commands/create-slash-command.ts +234 -0
- package/dist/resources/extensions/slash-commands/index.ts +12 -0
- package/dist/resources/extensions/subagent/agents.ts +126 -0
- package/dist/resources/extensions/subagent/index.ts +1072 -0
- package/dist/resources/extensions/subagent/isolation.ts +498 -0
- package/dist/resources/extensions/ttsr/index.ts +163 -0
- package/dist/resources/extensions/ttsr/rule-loader.ts +121 -0
- package/dist/resources/extensions/ttsr/ttsr-interrupt.md +6 -0
- package/dist/resources/extensions/ttsr/ttsr-manager.ts +430 -0
- package/dist/resources/extensions/universal-config/discovery.ts +78 -0
- package/dist/resources/extensions/universal-config/format.ts +160 -0
- package/dist/resources/extensions/universal-config/index.ts +118 -0
- package/dist/resources/extensions/universal-config/package.json +11 -0
- package/dist/resources/extensions/universal-config/scanners.ts +579 -0
- package/dist/resources/extensions/universal-config/tests/discovery.test.ts +111 -0
- package/dist/resources/extensions/universal-config/tests/format.test.ts +111 -0
- package/dist/resources/extensions/universal-config/tests/scanners.test.ts +438 -0
- package/dist/resources/extensions/universal-config/tools.ts +60 -0
- package/dist/resources/extensions/universal-config/types.ts +116 -0
- package/dist/resources/extensions/voice/index.ts +271 -0
- package/dist/resources/extensions/voice/speech-recognizer.py +504 -0
- package/dist/resources/extensions/voice/speech-recognizer.swift +154 -0
- package/dist/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/dist/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/dist/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/dist/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/dist/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/dist/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/dist/resources/skills/frontend-design/SKILL.md +45 -0
- package/dist/resources/skills/github-workflows/SKILL.md +92 -0
- package/dist/resources/skills/github-workflows/references/gh/SKILL.md +255 -0
- package/dist/resources/skills/github-workflows/references/gh/references/issue-stories.md +204 -0
- package/dist/resources/skills/github-workflows/references/gh/references/labels.md +170 -0
- package/dist/resources/skills/github-workflows/references/gh/references/milestones.md +158 -0
- package/dist/resources/skills/github-workflows/references/gh/references/projects-v2.md +177 -0
- package/dist/resources/skills/github-workflows/references/gh/scripts/experiment_cleanup.py +191 -0
- package/dist/resources/skills/github-workflows/references/gh/scripts/github_project_setup.py +799 -0
- package/dist/resources/skills/github-workflows/references/gh/tests/__init__.py +0 -0
- package/dist/resources/skills/github-workflows/references/gh/tests/test_github_project_setup.py +608 -0
- package/dist/resources/skills/swiftui/SKILL.md +208 -0
- package/dist/resources/skills/swiftui/references/animations.md +921 -0
- package/dist/resources/skills/swiftui/references/architecture.md +1561 -0
- package/dist/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/dist/resources/skills/swiftui/references/navigation.md +1492 -0
- package/dist/resources/skills/swiftui/references/networking-async.md +214 -0
- package/dist/resources/skills/swiftui/references/performance.md +1706 -0
- package/dist/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/dist/resources/skills/swiftui/references/state-management.md +1443 -0
- package/dist/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/dist/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/dist/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/dist/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/dist/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/dist/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/dist/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/dist/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/dist/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/wizard.js +1 -0
- package/package.json +8 -6
- package/packages/native/dist/fd/index.d.ts +1 -0
- package/packages/native/dist/fd/index.js +1 -0
- package/packages/native/dist/grep/index.d.ts +2 -1
- package/packages/native/dist/grep/index.js +1 -0
- package/packages/native/src/__tests__/fd.test.mjs +35 -0
- package/packages/native/src/__tests__/grep.test.mjs +26 -9
- package/packages/native/src/fd/index.ts +1 -0
- package/packages/native/src/grep/index.ts +3 -2
- package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +169 -55
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/dist/agent.d.ts +13 -1
- package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent.js +16 -0
- package/packages/pi-agent-core/dist/agent.js.map +1 -1
- package/packages/pi-agent-core/dist/types.d.ts +91 -1
- package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/types.js.map +1 -1
- package/packages/pi-agent-core/src/agent-loop.ts +273 -63
- package/packages/pi-agent-core/src/agent.ts +24 -0
- package/packages/pi-agent-core/src/types.ts +98 -0
- package/packages/pi-ai/dist/env-api-keys.js +2 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +314 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +236 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-gemini-cli.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-gemini-cli.js +6 -0
- package/packages/pi-ai/dist/providers/google-gemini-cli.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +2 -0
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +1 -1
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/src/env-api-keys.ts +2 -0
- package/packages/pi-ai/src/models.generated.ts +236 -0
- package/packages/pi-ai/src/providers/google-gemini-cli.ts +8 -0
- package/packages/pi-ai/src/providers/openai-completions.ts +3 -0
- package/packages/pi-ai/src/types.ts +2 -1
- package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +1 -0
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +26 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +137 -22
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +20 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/auth-storage.js +43 -0
- package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/fallback-resolver.d.ts +51 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.js +119 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.test.js +183 -0
- package/packages/pi-coding-agent/dist/core/fallback-resolver.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/index.d.ts +1 -0
- package/packages/pi-coding-agent/dist/core/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/index.js +1 -0
- package/packages/pi-coding-agent/dist/core/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.d.ts +12 -1
- package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.js +37 -1
- package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/session-manager.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/session-manager.test.js +61 -0
- package/packages/pi-coding-agent/dist/core/session-manager.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +20 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +47 -0
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/find.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/find.js +1 -0
- package/packages/pi-coding-agent/dist/core/tools/find.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +6 -15
- package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts +18 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +46 -4
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +25 -2
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +1 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +167 -22
- package/packages/pi-coding-agent/src/core/auth-storage.ts +45 -0
- package/packages/pi-coding-agent/src/core/fallback-resolver.test.ts +229 -0
- package/packages/pi-coding-agent/src/core/fallback-resolver.ts +165 -0
- package/packages/pi-coding-agent/src/core/index.ts +1 -0
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/packages/pi-coding-agent/src/core/session-manager.test.ts +65 -0
- package/packages/pi-coding-agent/src/core/session-manager.ts +51 -1
- package/packages/pi-coding-agent/src/core/settings-manager.ts +66 -0
- package/packages/pi-coding-agent/src/core/tools/find.ts +1 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +6 -16
- package/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +49 -4
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +28 -2
- package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
- package/packages/pi-tui/dist/autocomplete.js +3 -4
- package/packages/pi-tui/dist/autocomplete.js.map +1 -1
- package/packages/pi-tui/dist/components/editor.d.ts +7 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +57 -50
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/autocomplete.ts +3 -5
- package/packages/pi-tui/src/components/editor.ts +70 -49
- package/pkg/dist/core/export-html/ansi-to-html.d.ts +22 -0
- package/pkg/dist/core/export-html/ansi-to-html.d.ts.map +1 -0
- package/pkg/dist/core/export-html/ansi-to-html.js +249 -0
- package/pkg/dist/core/export-html/ansi-to-html.js.map +1 -0
- package/pkg/dist/core/export-html/index.d.ts +37 -0
- package/pkg/dist/core/export-html/index.d.ts.map +1 -0
- package/pkg/dist/core/export-html/index.js +223 -0
- package/pkg/dist/core/export-html/index.js.map +1 -0
- package/pkg/dist/core/export-html/template.css +971 -0
- package/pkg/dist/core/export-html/template.html +54 -0
- package/pkg/dist/core/export-html/template.js +1583 -0
- package/pkg/dist/core/export-html/tool-renderer.d.ts +38 -0
- package/pkg/dist/core/export-html/tool-renderer.d.ts.map +1 -0
- package/pkg/dist/core/export-html/tool-renderer.js +70 -0
- package/pkg/dist/core/export-html/tool-renderer.js.map +1 -0
- package/pkg/dist/core/export-html/vendor/highlight.min.js +1213 -0
- package/pkg/dist/core/export-html/vendor/marked.min.js +6 -0
- package/scripts/postinstall.js +30 -17
- package/src/resources/extensions/bg-shell/index.ts +51 -7
- package/src/resources/extensions/gsd/activity-log.ts +61 -14
- package/src/resources/extensions/gsd/auto.ts +288 -19
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/dashboard-overlay.ts +55 -12
- package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -0
- package/src/resources/extensions/gsd/doctor.ts +60 -3
- package/src/resources/extensions/gsd/files.ts +45 -1
- package/src/resources/extensions/gsd/git-service.ts +46 -41
- package/src/resources/extensions/gsd/guided-flow.ts +81 -9
- package/src/resources/extensions/gsd/index.ts +62 -1
- package/src/resources/extensions/gsd/native-git-bridge.ts +181 -0
- package/src/resources/extensions/gsd/paths.ts +35 -4
- package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/src/resources/extensions/gsd/preferences.ts +232 -1
- package/src/resources/extensions/gsd/prompt-loader.ts +45 -3
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/queue.md +3 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/state.ts +130 -18
- package/src/resources/extensions/gsd/templates/context.md +1 -1
- package/src/resources/extensions/gsd/templates/state.md +3 -3
- package/src/resources/extensions/gsd/tests/activity-log-save.test.ts +127 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +5 -1
- package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +6 -2
- package/src/resources/extensions/gsd/tests/git-service.test.ts +35 -35
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +8 -8
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +4 -1
- package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +54 -0
- package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +6 -0
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/worktree.test.ts +19 -19
- package/src/resources/extensions/gsd/types.ts +109 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +72 -13
- package/src/resources/extensions/gsd/worktree.ts +5 -4
- package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/src/resources/extensions/search-the-web/native-search.ts +32 -23
- package/src/resources/extensions/search-the-web/provider.ts +19 -2
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
- package/src/resources/extensions/shared/bundled-extension-paths.ts +11 -0
- package/src/resources/extensions/subagent/index.ts +2 -1
- package/src/resources/skills/github-workflows/SKILL.md +5 -0
- package/dist/resources/extensions/gsd/git-service.d.ts +0 -225
- package/dist/resources/extensions/gsd/git-service.js +0 -756
- package/dist/resources/extensions/gsd/preferences.d.ts +0 -122
- package/dist/resources/extensions/gsd/preferences.js +0 -616
- package/dist/resources/extensions/gsd/worktree.d.ts +0 -106
- package/dist/resources/extensions/remote-questions/config.d.ts +0 -14
- package/dist/resources/extensions/remote-questions/config.js +0 -68
- package/dist/resources/extensions/remote-questions/discord-adapter.d.ts +0 -17
- package/dist/resources/extensions/remote-questions/discord-adapter.js +0 -118
- package/dist/resources/extensions/remote-questions/format.d.ts +0 -38
- package/dist/resources/extensions/remote-questions/format.js +0 -119
- package/dist/resources/extensions/remote-questions/manager.d.ts +0 -23
- package/dist/resources/extensions/remote-questions/manager.js +0 -161
- package/dist/resources/extensions/remote-questions/remote-command.d.ts +0 -6
- package/dist/resources/extensions/remote-questions/remote-command.js +0 -358
- package/dist/resources/extensions/remote-questions/slack-adapter.d.ts +0 -15
- package/dist/resources/extensions/remote-questions/slack-adapter.js +0 -82
- package/dist/resources/extensions/remote-questions/status.d.ts +0 -9
- package/dist/resources/extensions/remote-questions/status.js +0 -25
- package/dist/resources/extensions/remote-questions/store.d.ts +0 -11
- package/dist/resources/extensions/remote-questions/store.js +0 -66
- package/dist/resources/extensions/remote-questions/types.d.ts +0 -68
- package/dist/resources/extensions/remote-questions/types.js +0 -4
|
@@ -0,0 +1,3732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Auto Mode — Fresh Session Per Unit
|
|
3
|
+
*
|
|
4
|
+
* State machine driven by .gsd/ files on disk. Each "unit" of work
|
|
5
|
+
* (plan slice, execute task, complete slice) gets a fresh session via
|
|
6
|
+
* the stashed ctx.newSession() pattern.
|
|
7
|
+
*
|
|
8
|
+
* The extension reads disk state after each agent_end, determines the
|
|
9
|
+
* next unit type, creates a fresh session, and injects a focused prompt
|
|
10
|
+
* telling the LLM which files to read and what to do.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
ExtensionCommandContext,
|
|
17
|
+
} from "@gsd/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import { deriveState, invalidateStateCache } from "./state.js";
|
|
20
|
+
import type { GSDState } from "./types.js";
|
|
21
|
+
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus, clearParseCache } from "./files.js";
|
|
22
|
+
export { inlinePriorMilestoneSummary };
|
|
23
|
+
import type { UatType } from "./files.js";
|
|
24
|
+
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
|
|
25
|
+
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
|
|
26
|
+
import {
|
|
27
|
+
gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
|
|
28
|
+
resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile,
|
|
29
|
+
relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath,
|
|
30
|
+
milestonesDir, resolveGsdRootFile, relGsdRootFile,
|
|
31
|
+
buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
|
|
32
|
+
clearPathCache,
|
|
33
|
+
} from "./paths.js";
|
|
34
|
+
import { saveActivityLog } from "./activity-log.js";
|
|
35
|
+
import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
|
|
36
|
+
import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
|
|
37
|
+
import {
|
|
38
|
+
clearUnitRuntimeRecord,
|
|
39
|
+
formatExecuteTaskRecoveryStatus,
|
|
40
|
+
inspectExecuteTaskDurability,
|
|
41
|
+
readUnitRuntimeRecord,
|
|
42
|
+
writeUnitRuntimeRecord,
|
|
43
|
+
} from "./unit-runtime.js";
|
|
44
|
+
import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js";
|
|
45
|
+
import type { GSDPreferences } from "./preferences.js";
|
|
46
|
+
import {
|
|
47
|
+
checkPostUnitHooks,
|
|
48
|
+
getActiveHook,
|
|
49
|
+
resetHookState,
|
|
50
|
+
isRetryPending,
|
|
51
|
+
consumeRetryTrigger,
|
|
52
|
+
runPreDispatchHooks,
|
|
53
|
+
persistHookState,
|
|
54
|
+
restoreHookState,
|
|
55
|
+
clearPersistedHookState,
|
|
56
|
+
formatHookStatus,
|
|
57
|
+
} from "./post-unit-hooks.js";
|
|
58
|
+
import {
|
|
59
|
+
validatePlanBoundary,
|
|
60
|
+
validateExecuteBoundary,
|
|
61
|
+
validateCompleteBoundary,
|
|
62
|
+
formatValidationIssues,
|
|
63
|
+
} from "./observability-validator.js";
|
|
64
|
+
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
|
|
65
|
+
import { runGSDDoctor, rebuildState } from "./doctor.js";
|
|
66
|
+
import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
|
|
67
|
+
import {
|
|
68
|
+
initMetrics, resetMetrics, snapshotUnitMetrics, getLedger,
|
|
69
|
+
getProjectTotals, formatCost, formatTokenCount,
|
|
70
|
+
} from "./metrics.js";
|
|
71
|
+
import { dirname, join } from "node:path";
|
|
72
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
73
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
74
|
+
import {
|
|
75
|
+
autoCommitCurrentBranch,
|
|
76
|
+
captureIntegrationBranch,
|
|
77
|
+
ensureSliceBranch,
|
|
78
|
+
getCurrentBranch,
|
|
79
|
+
getMainBranch,
|
|
80
|
+
MergeConflictError,
|
|
81
|
+
parseSliceBranch,
|
|
82
|
+
setActiveMilestoneId,
|
|
83
|
+
switchToMain,
|
|
84
|
+
mergeSliceToMain,
|
|
85
|
+
} from "./worktree.js";
|
|
86
|
+
import { GitServiceImpl, runGit } from "./git-service.js";
|
|
87
|
+
import { nativeCommitCountBetween } from "./native-git-bridge.js";
|
|
88
|
+
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
|
|
89
|
+
import type { GitPreferences } from "./git-service.js";
|
|
90
|
+
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
91
|
+
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
|
|
92
|
+
import { showNextAction } from "../shared/next-action-ui.js";
|
|
93
|
+
|
|
94
|
+
// ─── Disk-backed completed-unit helpers ───────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Path to the persisted completed-unit keys file. */
|
|
97
|
+
function completedKeysPath(base: string): string {
|
|
98
|
+
return join(base, ".gsd", "completed-units.json");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write a completed unit key to disk (read-modify-write append to set). */
|
|
102
|
+
function persistCompletedKey(base: string, key: string): void {
|
|
103
|
+
const file = completedKeysPath(base);
|
|
104
|
+
let keys: string[] = [];
|
|
105
|
+
try {
|
|
106
|
+
if (existsSync(file)) {
|
|
107
|
+
keys = JSON.parse(readFileSync(file, "utf-8"));
|
|
108
|
+
}
|
|
109
|
+
} catch { /* corrupt file — start fresh */ }
|
|
110
|
+
if (!keys.includes(key)) {
|
|
111
|
+
keys.push(key);
|
|
112
|
+
writeFileSync(file, JSON.stringify(keys), "utf-8");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remove a stale completed unit key from disk. */
|
|
117
|
+
function removePersistedKey(base: string, key: string): void {
|
|
118
|
+
const file = completedKeysPath(base);
|
|
119
|
+
try {
|
|
120
|
+
if (existsSync(file)) {
|
|
121
|
+
let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
|
|
122
|
+
keys = keys.filter(k => k !== key);
|
|
123
|
+
writeFileSync(file, JSON.stringify(keys), "utf-8");
|
|
124
|
+
}
|
|
125
|
+
} catch { /* non-fatal */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Load all completed unit keys from disk into the in-memory set. */
|
|
129
|
+
function loadPersistedKeys(base: string, target: Set<string>): void {
|
|
130
|
+
const file = completedKeysPath(base);
|
|
131
|
+
try {
|
|
132
|
+
if (existsSync(file)) {
|
|
133
|
+
const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
|
|
134
|
+
for (const k of keys) target.add(k);
|
|
135
|
+
}
|
|
136
|
+
} catch { /* non-fatal */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
let active = false;
|
|
142
|
+
let paused = false;
|
|
143
|
+
let stepMode = false;
|
|
144
|
+
let verbose = false;
|
|
145
|
+
let cmdCtx: ExtensionCommandContext | null = null;
|
|
146
|
+
let basePath = "";
|
|
147
|
+
let gitService: GitServiceImpl | null = null;
|
|
148
|
+
|
|
149
|
+
/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
|
|
150
|
+
const unitDispatchCount = new Map<string, number>();
|
|
151
|
+
const MAX_UNIT_DISPATCHES = 3;
|
|
152
|
+
/** Retry index at which a stub summary placeholder is written when the summary is still absent. */
|
|
153
|
+
const STUB_RECOVERY_THRESHOLD = 2;
|
|
154
|
+
|
|
155
|
+
/** Tracks recovery attempt count per unit for backoff and diagnostics. */
|
|
156
|
+
const unitRecoveryCount = new Map<string, number>();
|
|
157
|
+
|
|
158
|
+
/** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
|
|
159
|
+
const completedKeySet = new Set<string>();
|
|
160
|
+
|
|
161
|
+
/** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
|
|
162
|
+
let pendingCrashRecovery: string | null = null;
|
|
163
|
+
|
|
164
|
+
/** Dashboard tracking */
|
|
165
|
+
let autoStartTime: number = 0;
|
|
166
|
+
let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
|
|
167
|
+
let currentUnit: { type: string; id: string; startedAt: number } | null = null;
|
|
168
|
+
|
|
169
|
+
/** Track current milestone to detect transitions */
|
|
170
|
+
let currentMilestoneId: string | null = null;
|
|
171
|
+
|
|
172
|
+
/** Model the user had selected before auto-mode started */
|
|
173
|
+
let originalModelId: string | null = null;
|
|
174
|
+
let originalModelProvider: string | null = null;
|
|
175
|
+
|
|
176
|
+
/** Progress-aware timeout supervision */
|
|
177
|
+
let unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
+
let wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
|
|
179
|
+
let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
|
|
180
|
+
|
|
181
|
+
/** Dispatch gap watchdog — detects when the state machine stalls between units.
|
|
182
|
+
* After handleAgentEnd completes, if auto-mode is still active but no new unit
|
|
183
|
+
* has been dispatched (sendMessage not called), this timer fires to force a
|
|
184
|
+
* re-evaluation. Covers the case where dispatchNextUnit silently fails or
|
|
185
|
+
* an unhandled error kills the dispatch chain. */
|
|
186
|
+
let dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
|
|
187
|
+
const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
|
|
188
|
+
|
|
189
|
+
/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
|
|
190
|
+
let _sigtermHandler: (() => void) | null = null;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register a SIGTERM handler that clears the lock file and exits cleanly.
|
|
194
|
+
* Captures the active base path at registration time so the handler
|
|
195
|
+
* always references the correct path even if the module variable changes.
|
|
196
|
+
* Removes any previously registered handler before installing the new one.
|
|
197
|
+
*/
|
|
198
|
+
function registerSigtermHandler(currentBasePath: string): void {
|
|
199
|
+
if (_sigtermHandler) process.off("SIGTERM", _sigtermHandler);
|
|
200
|
+
_sigtermHandler = () => {
|
|
201
|
+
clearLock(currentBasePath);
|
|
202
|
+
process.exit(0);
|
|
203
|
+
};
|
|
204
|
+
process.on("SIGTERM", _sigtermHandler);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Deregister the SIGTERM handler (called on stop/pause). */
|
|
208
|
+
function deregisterSigtermHandler(): void {
|
|
209
|
+
if (_sigtermHandler) {
|
|
210
|
+
process.off("SIGTERM", _sigtermHandler);
|
|
211
|
+
_sigtermHandler = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Format token counts for compact display */
|
|
216
|
+
function formatWidgetTokens(count: number): string {
|
|
217
|
+
if (count < 1000) return count.toString();
|
|
218
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
219
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
220
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
221
|
+
return `${Math.round(count / 1000000)}M`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Footer factory that renders zero lines — hides the built-in footer entirely.
|
|
226
|
+
* All footer info (pwd, branch, tokens, cost, model) is shown inside the
|
|
227
|
+
* progress widget instead, so there's no gap or redundancy.
|
|
228
|
+
*/
|
|
229
|
+
const hideFooter = () => ({
|
|
230
|
+
render(_width: number): string[] { return []; },
|
|
231
|
+
invalidate() {},
|
|
232
|
+
dispose() {},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/** Dashboard data for the overlay */
|
|
236
|
+
export interface AutoDashboardData {
|
|
237
|
+
active: boolean;
|
|
238
|
+
paused: boolean;
|
|
239
|
+
stepMode: boolean;
|
|
240
|
+
startTime: number;
|
|
241
|
+
elapsed: number;
|
|
242
|
+
currentUnit: { type: string; id: string; startedAt: number } | null;
|
|
243
|
+
completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[];
|
|
244
|
+
basePath: string;
|
|
245
|
+
/** Running cost and token totals from metrics ledger */
|
|
246
|
+
totalCost: number;
|
|
247
|
+
totalTokens: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getAutoDashboardData(): AutoDashboardData {
|
|
251
|
+
const ledger = getLedger();
|
|
252
|
+
const totals = ledger ? getProjectTotals(ledger.units) : null;
|
|
253
|
+
return {
|
|
254
|
+
active,
|
|
255
|
+
paused,
|
|
256
|
+
stepMode,
|
|
257
|
+
startTime: autoStartTime,
|
|
258
|
+
elapsed: (active || paused) ? Date.now() - autoStartTime : 0,
|
|
259
|
+
currentUnit: currentUnit ? { ...currentUnit } : null,
|
|
260
|
+
completedUnits: [...completedUnits],
|
|
261
|
+
basePath,
|
|
262
|
+
totalCost: totals?.cost ?? 0,
|
|
263
|
+
totalTokens: totals?.tokens.total ?? 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export function isAutoActive(): boolean {
|
|
270
|
+
return active;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function isAutoPaused(): boolean {
|
|
274
|
+
return paused;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function isStepMode(): boolean {
|
|
278
|
+
return stepMode;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clearUnitTimeout(): void {
|
|
282
|
+
if (unitTimeoutHandle) {
|
|
283
|
+
clearTimeout(unitTimeoutHandle);
|
|
284
|
+
unitTimeoutHandle = null;
|
|
285
|
+
}
|
|
286
|
+
if (wrapupWarningHandle) {
|
|
287
|
+
clearTimeout(wrapupWarningHandle);
|
|
288
|
+
wrapupWarningHandle = null;
|
|
289
|
+
}
|
|
290
|
+
if (idleWatchdogHandle) {
|
|
291
|
+
clearInterval(idleWatchdogHandle);
|
|
292
|
+
idleWatchdogHandle = null;
|
|
293
|
+
}
|
|
294
|
+
clearDispatchGapWatchdog();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function clearDispatchGapWatchdog(): void {
|
|
298
|
+
if (dispatchGapHandle) {
|
|
299
|
+
clearTimeout(dispatchGapHandle);
|
|
300
|
+
dispatchGapHandle = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
|
|
306
|
+
* after handleAgentEnd completes. This catches the case where the dispatch chain silently
|
|
307
|
+
* breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left active but idle.
|
|
308
|
+
*
|
|
309
|
+
* The watchdog is cleared on the next successful unit dispatch (clearUnitTimeout is called
|
|
310
|
+
* at the start of handleAgentEnd, which calls clearDispatchGapWatchdog).
|
|
311
|
+
*/
|
|
312
|
+
function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void {
|
|
313
|
+
clearDispatchGapWatchdog();
|
|
314
|
+
dispatchGapHandle = setTimeout(async () => {
|
|
315
|
+
dispatchGapHandle = null;
|
|
316
|
+
if (!active || !cmdCtx) return;
|
|
317
|
+
|
|
318
|
+
// Auto-mode is active but no unit was dispatched — the state machine stalled.
|
|
319
|
+
// Re-derive state and attempt a fresh dispatch.
|
|
320
|
+
ctx.ui.notify(
|
|
321
|
+
"Dispatch gap detected — no unit dispatched after previous unit completed. Re-evaluating state.",
|
|
322
|
+
"warning",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await dispatchNextUnit(ctx, pi);
|
|
327
|
+
} catch (retryErr) {
|
|
328
|
+
const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
`Dispatch gap recovery failed: ${message}. Stopping auto-mode.`,
|
|
331
|
+
"error",
|
|
332
|
+
);
|
|
333
|
+
await stopAuto(ctx, pi);
|
|
334
|
+
}
|
|
335
|
+
}, DISPATCH_GAP_TIMEOUT_MS);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
|
|
339
|
+
if (!active && !paused) return;
|
|
340
|
+
clearUnitTimeout();
|
|
341
|
+
if (basePath) clearLock(basePath);
|
|
342
|
+
clearSkillSnapshot();
|
|
343
|
+
|
|
344
|
+
// Remove SIGTERM handler registered at auto-mode start
|
|
345
|
+
deregisterSigtermHandler();
|
|
346
|
+
const ledger = getLedger();
|
|
347
|
+
if (ledger && ledger.units.length > 0) {
|
|
348
|
+
const totals = getProjectTotals(ledger.units);
|
|
349
|
+
ctx?.ui.notify(
|
|
350
|
+
`Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
|
|
351
|
+
"info",
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
ctx?.ui.notify("Auto-mode stopped.", "info");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Sync disk state so next resume starts from accurate state
|
|
358
|
+
if (basePath) {
|
|
359
|
+
try { await rebuildState(basePath); } catch { /* non-fatal */ }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
resetMetrics();
|
|
363
|
+
resetHookState();
|
|
364
|
+
if (basePath) clearPersistedHookState(basePath);
|
|
365
|
+
active = false;
|
|
366
|
+
paused = false;
|
|
367
|
+
stepMode = false;
|
|
368
|
+
unitDispatchCount.clear();
|
|
369
|
+
unitRecoveryCount.clear();
|
|
370
|
+
currentUnit = null;
|
|
371
|
+
currentMilestoneId = null;
|
|
372
|
+
cachedSliceProgress = null;
|
|
373
|
+
pendingCrashRecovery = null;
|
|
374
|
+
_handlingAgentEnd = false;
|
|
375
|
+
ctx?.ui.setStatus("gsd-auto", undefined);
|
|
376
|
+
ctx?.ui.setWidget("gsd-progress", undefined);
|
|
377
|
+
ctx?.ui.setFooter(undefined);
|
|
378
|
+
|
|
379
|
+
// Restore the user's original model
|
|
380
|
+
if (pi && ctx && originalModelId && originalModelProvider) {
|
|
381
|
+
const original = ctx.modelRegistry.find(originalModelProvider, originalModelId);
|
|
382
|
+
if (original) await pi.setModel(original);
|
|
383
|
+
originalModelId = null;
|
|
384
|
+
originalModelProvider = null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
cmdCtx = null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Pause auto-mode without destroying state. Context is preserved.
|
|
392
|
+
* The user can interact with the agent, then `/gsd auto` resumes
|
|
393
|
+
* from disk state. Called when the user presses Escape during auto-mode.
|
|
394
|
+
*/
|
|
395
|
+
export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise<void> {
|
|
396
|
+
if (!active) return;
|
|
397
|
+
clearUnitTimeout();
|
|
398
|
+
if (basePath) clearLock(basePath);
|
|
399
|
+
|
|
400
|
+
// Remove SIGTERM handler registered at auto-mode start
|
|
401
|
+
deregisterSigtermHandler();
|
|
402
|
+
|
|
403
|
+
active = false;
|
|
404
|
+
paused = true;
|
|
405
|
+
// Preserve: unitDispatchCount, currentUnit, basePath, verbose, cmdCtx,
|
|
406
|
+
// completedUnits, autoStartTime, currentMilestoneId, originalModelId
|
|
407
|
+
// — all needed for resume and dashboard display
|
|
408
|
+
ctx?.ui.setStatus("gsd-auto", "paused");
|
|
409
|
+
ctx?.ui.setWidget("gsd-progress", undefined);
|
|
410
|
+
ctx?.ui.setFooter(undefined);
|
|
411
|
+
const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
|
|
412
|
+
ctx?.ui.notify(
|
|
413
|
+
`${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
|
|
414
|
+
"info",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Self-heal: scan runtime records in .gsd/ and clear any where the expected
|
|
420
|
+
* artifact already exists on disk. This repairs incomplete closeouts from
|
|
421
|
+
* prior crashes — preventing spurious re-dispatch of already-completed units.
|
|
422
|
+
*/
|
|
423
|
+
async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Promise<void> {
|
|
424
|
+
try {
|
|
425
|
+
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
|
|
426
|
+
const records = listUnitRuntimeRecords(base);
|
|
427
|
+
let healed = 0;
|
|
428
|
+
for (const record of records) {
|
|
429
|
+
const { unitType, unitId } = record;
|
|
430
|
+
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
431
|
+
if (artifactPath && existsSync(artifactPath)) {
|
|
432
|
+
// Artifact exists — unit completed but closeout didn't finish.
|
|
433
|
+
clearUnitRuntimeRecord(base, unitType, unitId);
|
|
434
|
+
healed++;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (healed > 0) {
|
|
438
|
+
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s) with completed artifacts.`, "info");
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
// Non-fatal — self-heal should never block auto-mode start
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Startup check: scan for orphaned completed slice branches and merge them.
|
|
447
|
+
*
|
|
448
|
+
* An orphaned completed slice branch is a `gsd/MID/SID` branch where the slice
|
|
449
|
+
* is marked done in the roadmap (on that branch) but hasn't been squash-merged
|
|
450
|
+
* to main yet. This happens when `complete-slice` succeeds and commits on the
|
|
451
|
+
* slice branch, but the subsequent merge to main is interrupted (crash, timeout,
|
|
452
|
+
* Ctrl+C, merge conflict that wasn't auto-resolved).
|
|
453
|
+
*
|
|
454
|
+
* Without this check, GSD gets stuck in an infinite loop: `deriveState()` on
|
|
455
|
+
* main sees no slice artifacts → wants research-slice → idempotency key removed
|
|
456
|
+
* (artifact not on main) → ensurePreconditions switches branch → merge guard
|
|
457
|
+
* merges → re-derives → repeats.
|
|
458
|
+
*/
|
|
459
|
+
async function mergeOrphanedSliceBranches(
|
|
460
|
+
base: string,
|
|
461
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
// List all local gsd/<MID>/<SID> branches (non-worktree pattern).
|
|
464
|
+
// Use execFileSync (not runGit/execSync) to avoid shell glob-expanding gsd/*/*
|
|
465
|
+
// and to avoid shell syntax errors from %(refname:short) on /bin/sh.
|
|
466
|
+
let branchListRaw = "";
|
|
467
|
+
try {
|
|
468
|
+
branchListRaw = execFileSync(
|
|
469
|
+
"git",
|
|
470
|
+
["branch", "--list", "gsd/*/*", "--format=%(refname:short)"],
|
|
471
|
+
{ cwd: base, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
|
472
|
+
).trim();
|
|
473
|
+
} catch {
|
|
474
|
+
return; // no slice branches or git unavailable
|
|
475
|
+
}
|
|
476
|
+
if (!branchListRaw) return;
|
|
477
|
+
|
|
478
|
+
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
|
|
479
|
+
for (const branch of branches) {
|
|
480
|
+
const parsed = parseSliceBranch(branch);
|
|
481
|
+
// Skip worktree-namespaced branches — those are managed by the worktree
|
|
482
|
+
// manager and should not be merged by the main-tree auto-mode.
|
|
483
|
+
if (!parsed || parsed.worktreeName) continue;
|
|
484
|
+
|
|
485
|
+
const { milestoneId, sliceId } = parsed;
|
|
486
|
+
|
|
487
|
+
// Ensure Git operations for this branch use the correct milestone context.
|
|
488
|
+
setActiveMilestoneId(base, milestoneId);
|
|
489
|
+
|
|
490
|
+
// Skip if already merged (no commits ahead of main)
|
|
491
|
+
const mainBranch = getMainBranch(base);
|
|
492
|
+
const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
|
|
493
|
+
if (aheadCount === 0) continue;
|
|
494
|
+
|
|
495
|
+
// Read the roadmap from the slice branch to check if the slice is done.
|
|
496
|
+
// relMilestoneFile resolves the actual directory name on disk (handles
|
|
497
|
+
// milestone directories with title suffixes like "M007 Payment System").
|
|
498
|
+
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
|
|
499
|
+
let roadmapContent: string | undefined;
|
|
500
|
+
try {
|
|
501
|
+
roadmapContent = execFileSync(
|
|
502
|
+
"git",
|
|
503
|
+
["-C", base, "show", `${branch}:${roadmapRelPath}`],
|
|
504
|
+
{ encoding: "utf8" },
|
|
505
|
+
);
|
|
506
|
+
} catch {
|
|
507
|
+
roadmapContent = undefined;
|
|
508
|
+
}
|
|
509
|
+
if (!roadmapContent) continue;
|
|
510
|
+
|
|
511
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
512
|
+
const sliceEntry = roadmap.slices.find(s => s.id === sliceId);
|
|
513
|
+
if (!sliceEntry?.done) continue;
|
|
514
|
+
|
|
515
|
+
// Orphaned completed branch detected — merge it to main now.
|
|
516
|
+
ctx.ui.notify(
|
|
517
|
+
`Orphaned completed slice branch detected: ${branch}. Merging to main before dispatch...`,
|
|
518
|
+
"info",
|
|
519
|
+
);
|
|
520
|
+
try {
|
|
521
|
+
switchToMain(base);
|
|
522
|
+
const mergeResult = mergeSliceToMain(
|
|
523
|
+
base, milestoneId, sliceId, sliceEntry.title || sliceId,
|
|
524
|
+
);
|
|
525
|
+
ctx.ui.notify(
|
|
526
|
+
`Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
|
|
527
|
+
"info",
|
|
528
|
+
);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (error instanceof MergeConflictError) {
|
|
531
|
+
// Abort and reset the incomplete merge so auto-mode can still start cleanly.
|
|
532
|
+
runGit(base, ["merge", "--abort"], { allowFailure: true });
|
|
533
|
+
runGit(base, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
534
|
+
ctx.ui.notify(
|
|
535
|
+
`Orphaned branch ${branch} has merge conflicts — resolve manually and restart.\nConflicts in: ${error.conflictedFiles.join(", ")}`,
|
|
536
|
+
"error",
|
|
537
|
+
);
|
|
538
|
+
// Stop processing further branches after a conflict to avoid
|
|
539
|
+
// leaving the repo in a partially-merged state.
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
543
|
+
ctx.ui.notify(
|
|
544
|
+
`Failed to merge orphaned branch ${branch}: ${message}`,
|
|
545
|
+
"warning",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function startAuto(
|
|
552
|
+
ctx: ExtensionCommandContext,
|
|
553
|
+
pi: ExtensionAPI,
|
|
554
|
+
base: string,
|
|
555
|
+
verboseMode: boolean,
|
|
556
|
+
options?: { step?: boolean },
|
|
557
|
+
): Promise<void> {
|
|
558
|
+
const requestedStepMode = options?.step ?? false;
|
|
559
|
+
|
|
560
|
+
// If resuming from paused state, just re-activate and dispatch next unit.
|
|
561
|
+
// The conversation is still intact — no need to reinitialize everything.
|
|
562
|
+
if (paused) {
|
|
563
|
+
paused = false;
|
|
564
|
+
active = true;
|
|
565
|
+
verbose = verboseMode;
|
|
566
|
+
// Allow switching between step/auto on resume
|
|
567
|
+
stepMode = requestedStepMode;
|
|
568
|
+
cmdCtx = ctx;
|
|
569
|
+
basePath = base;
|
|
570
|
+
unitDispatchCount.clear();
|
|
571
|
+
// Re-initialize metrics in case ledger was lost during pause
|
|
572
|
+
if (!getLedger()) initMetrics(base);
|
|
573
|
+
// Ensure milestone ID is set on git service for integration branch resolution
|
|
574
|
+
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
|
|
575
|
+
|
|
576
|
+
// Re-register SIGTERM handler for the resumed session
|
|
577
|
+
registerSigtermHandler(base);
|
|
578
|
+
|
|
579
|
+
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
580
|
+
ctx.ui.setFooter(hideFooter);
|
|
581
|
+
ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
|
|
582
|
+
// Restore hook state from disk in case session was interrupted
|
|
583
|
+
restoreHookState(base);
|
|
584
|
+
// Rebuild disk state before resuming — user interaction during pause may have changed files
|
|
585
|
+
try { await rebuildState(base); } catch { /* non-fatal */ }
|
|
586
|
+
try {
|
|
587
|
+
const report = await runGSDDoctor(base, { fix: true });
|
|
588
|
+
if (report.fixesApplied.length > 0) {
|
|
589
|
+
ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
|
|
590
|
+
}
|
|
591
|
+
} catch { /* non-fatal */ }
|
|
592
|
+
// Self-heal: clear stale runtime records where artifacts already exist
|
|
593
|
+
await selfHealRuntimeRecords(base, ctx);
|
|
594
|
+
invalidateStateCache();
|
|
595
|
+
clearParseCache();
|
|
596
|
+
clearPathCache();
|
|
597
|
+
await dispatchNextUnit(ctx, pi);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Ensure git repo exists — GSD needs it for branch-per-slice
|
|
602
|
+
try {
|
|
603
|
+
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
|
|
604
|
+
} catch {
|
|
605
|
+
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
|
|
606
|
+
execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Ensure .gitignore has baseline patterns
|
|
610
|
+
ensureGitignore(base);
|
|
611
|
+
untrackRuntimeFiles(base);
|
|
612
|
+
|
|
613
|
+
// Bootstrap .gsd/ if it doesn't exist
|
|
614
|
+
const gsdDir = join(base, ".gsd");
|
|
615
|
+
if (!existsSync(gsdDir)) {
|
|
616
|
+
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
|
|
617
|
+
try {
|
|
618
|
+
execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", {
|
|
619
|
+
cwd: base, stdio: "pipe",
|
|
620
|
+
});
|
|
621
|
+
} catch { /* nothing to commit */ }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Initialize GitServiceImpl — basePath is set and git repo confirmed
|
|
625
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
626
|
+
|
|
627
|
+
// Check for crash from previous session
|
|
628
|
+
const crashLock = readCrashLock(base);
|
|
629
|
+
if (crashLock) {
|
|
630
|
+
if (isLockProcessAlive(crashLock)) {
|
|
631
|
+
// The lock belongs to a process that is still running — not a crash.
|
|
632
|
+
// Warn the user and abort to avoid two concurrent auto-mode sessions.
|
|
633
|
+
ctx.ui.notify(
|
|
634
|
+
`Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`,
|
|
635
|
+
"error",
|
|
636
|
+
);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Stale lock from a dead process — synthesize crash recovery context.
|
|
640
|
+
const activityDir = join(gsdRoot(base), "activity");
|
|
641
|
+
const recovery = synthesizeCrashRecovery(
|
|
642
|
+
base, crashLock.unitType, crashLock.unitId,
|
|
643
|
+
crashLock.sessionFile, activityDir,
|
|
644
|
+
);
|
|
645
|
+
if (recovery && recovery.trace.toolCallCount > 0) {
|
|
646
|
+
pendingCrashRecovery = recovery.prompt;
|
|
647
|
+
ctx.ui.notify(
|
|
648
|
+
`${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
|
|
649
|
+
"warning",
|
|
650
|
+
);
|
|
651
|
+
} else {
|
|
652
|
+
ctx.ui.notify(
|
|
653
|
+
`${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
|
|
654
|
+
"warning",
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
clearLock(base);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const state = await deriveState(base);
|
|
661
|
+
|
|
662
|
+
// No active work at all — start a new milestone via the discuss flow.
|
|
663
|
+
if (!state.activeMilestone || state.phase === "complete") {
|
|
664
|
+
const { showSmartEntry } = await import("./guided-flow.js");
|
|
665
|
+
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Active milestone exists but has no roadmap — check if context exists.
|
|
670
|
+
// If context was pre-written (multi-milestone planning), auto-mode can
|
|
671
|
+
// research and plan it. If no context either, need user discussion.
|
|
672
|
+
if (state.phase === "pre-planning") {
|
|
673
|
+
const contextFile = resolveMilestoneFile(base, state.activeMilestone.id, "CONTEXT");
|
|
674
|
+
const hasContext = !!(contextFile && await loadFile(contextFile));
|
|
675
|
+
if (!hasContext) {
|
|
676
|
+
const { showSmartEntry } = await import("./guided-flow.js");
|
|
677
|
+
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// Has context, no roadmap — auto-mode will research + plan it
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
active = true;
|
|
684
|
+
stepMode = requestedStepMode;
|
|
685
|
+
verbose = verboseMode;
|
|
686
|
+
cmdCtx = ctx;
|
|
687
|
+
basePath = base;
|
|
688
|
+
unitDispatchCount.clear();
|
|
689
|
+
unitRecoveryCount.clear();
|
|
690
|
+
completedKeySet.clear();
|
|
691
|
+
loadPersistedKeys(base, completedKeySet);
|
|
692
|
+
resetHookState();
|
|
693
|
+
restoreHookState(base);
|
|
694
|
+
autoStartTime = Date.now();
|
|
695
|
+
completedUnits = [];
|
|
696
|
+
currentUnit = null;
|
|
697
|
+
currentMilestoneId = state.activeMilestone?.id ?? null;
|
|
698
|
+
originalModelId = ctx.model?.id ?? null;
|
|
699
|
+
originalModelProvider = ctx.model?.provider ?? null;
|
|
700
|
+
|
|
701
|
+
// Register a SIGTERM handler so `kill <pid>` cleans up the lock and exits.
|
|
702
|
+
registerSigtermHandler(base);
|
|
703
|
+
|
|
704
|
+
// Capture the integration branch — records the branch the user was on when
|
|
705
|
+
// auto-mode started. Slice branches will merge back to this branch instead
|
|
706
|
+
// of the repo's default (main/master). Idempotent when the branch is the
|
|
707
|
+
// same; updates the record when started from a different branch (#300).
|
|
708
|
+
if (currentMilestoneId) {
|
|
709
|
+
captureIntegrationBranch(base, currentMilestoneId);
|
|
710
|
+
setActiveMilestoneId(base, currentMilestoneId);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Initialize metrics — loads existing ledger from disk
|
|
714
|
+
initMetrics(base);
|
|
715
|
+
|
|
716
|
+
// Snapshot installed skills so we can detect new ones after research
|
|
717
|
+
if (resolveSkillDiscoveryMode() !== "off") {
|
|
718
|
+
snapshotSkills();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
722
|
+
ctx.ui.setFooter(hideFooter);
|
|
723
|
+
const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
|
|
724
|
+
const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
|
|
725
|
+
const scopeMsg = pendingCount > 1
|
|
726
|
+
? `Will loop through ${pendingCount} milestones.`
|
|
727
|
+
: "Will loop until milestone complete.";
|
|
728
|
+
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
|
|
729
|
+
|
|
730
|
+
// Secrets collection gate — collect pending secrets before first dispatch
|
|
731
|
+
const mid = state.activeMilestone.id;
|
|
732
|
+
try {
|
|
733
|
+
const manifestStatus = await getManifestStatus(base, mid);
|
|
734
|
+
if (manifestStatus && manifestStatus.pending.length > 0) {
|
|
735
|
+
const result = await collectSecretsFromManifest(base, mid, ctx);
|
|
736
|
+
if (result && result.applied && result.skipped && result.existingSkipped) {
|
|
737
|
+
ctx.ui.notify(
|
|
738
|
+
`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
|
|
739
|
+
"info",
|
|
740
|
+
);
|
|
741
|
+
} else {
|
|
742
|
+
ctx.ui.notify("Secrets collection skipped.", "info");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
} catch (err) {
|
|
746
|
+
ctx.ui.notify(
|
|
747
|
+
`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
|
|
748
|
+
"warning",
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Merge any orphaned completed slice branches before dispatching.
|
|
753
|
+
// Orphaned branches arise when complete-slice commits on the slice branch
|
|
754
|
+
// but the merge to main is interrupted (crash, timeout, Ctrl+C).
|
|
755
|
+
// Without this check, GSD enters an infinite "Skipping ... Advancing" loop.
|
|
756
|
+
await mergeOrphanedSliceBranches(base, ctx);
|
|
757
|
+
|
|
758
|
+
// Self-heal: clear stale runtime records where artifacts already exist
|
|
759
|
+
await selfHealRuntimeRecords(base, ctx);
|
|
760
|
+
|
|
761
|
+
// Dispatch the first unit
|
|
762
|
+
await dispatchNextUnit(ctx, pi);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── Agent End Handler ────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
/** Guard against concurrent handleAgentEnd execution. Background job
|
|
768
|
+
* notifications and other system messages can trigger multiple agent_end
|
|
769
|
+
* events before the first handler finishes (the handler yields at every
|
|
770
|
+
* await). Without this guard, concurrent dispatchNextUnit calls race on
|
|
771
|
+
* newSession(), causing one to cancel the other and silently stopping
|
|
772
|
+
* auto-mode. */
|
|
773
|
+
let _handlingAgentEnd = false;
|
|
774
|
+
|
|
775
|
+
export async function handleAgentEnd(
|
|
776
|
+
ctx: ExtensionContext,
|
|
777
|
+
pi: ExtensionAPI,
|
|
778
|
+
): Promise<void> {
|
|
779
|
+
if (!active || !cmdCtx) return;
|
|
780
|
+
if (_handlingAgentEnd) return;
|
|
781
|
+
_handlingAgentEnd = true;
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
|
|
785
|
+
// Unit completed — clear its timeout
|
|
786
|
+
clearUnitTimeout();
|
|
787
|
+
|
|
788
|
+
// Invalidate deriveState() cache — the unit just completed and may have
|
|
789
|
+
// written planning files (task summaries, roadmap checkboxes, etc.)
|
|
790
|
+
invalidateStateCache();
|
|
791
|
+
clearParseCache();
|
|
792
|
+
clearPathCache();
|
|
793
|
+
|
|
794
|
+
// Small delay to let files settle (git commits, file writes)
|
|
795
|
+
await new Promise(r => setTimeout(r, 500));
|
|
796
|
+
|
|
797
|
+
// Auto-commit any dirty files the LLM left behind on the current branch.
|
|
798
|
+
if (currentUnit) {
|
|
799
|
+
try {
|
|
800
|
+
const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
|
|
801
|
+
if (commitMsg) {
|
|
802
|
+
ctx.ui.notify(`Auto-committed uncommitted changes.`, "info");
|
|
803
|
+
}
|
|
804
|
+
} catch {
|
|
805
|
+
// Non-fatal
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Post-hook: fix mechanical bookkeeping the LLM may have skipped.
|
|
809
|
+
// 1. Doctor handles: checkbox marking (task-level bookkeeping).
|
|
810
|
+
// 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed).
|
|
811
|
+
// fixLevel:"task" ensures doctor only fixes task-level issues (e.g. marking
|
|
812
|
+
// checkboxes). Slice/milestone completion transitions (summary stubs,
|
|
813
|
+
// roadmap [x] marking) are left for the complete-slice dispatch unit.
|
|
814
|
+
try {
|
|
815
|
+
const scopeParts = currentUnit.id.split("/").slice(0, 2);
|
|
816
|
+
const doctorScope = scopeParts.join("/");
|
|
817
|
+
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: "task" });
|
|
818
|
+
if (report.fixesApplied.length > 0) {
|
|
819
|
+
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
|
|
820
|
+
}
|
|
821
|
+
} catch {
|
|
822
|
+
// Non-fatal — doctor failure should never block dispatch
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
await rebuildState(basePath);
|
|
826
|
+
autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
|
|
827
|
+
} catch {
|
|
828
|
+
// Non-fatal
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
|
|
833
|
+
if (currentUnit && !stepMode) {
|
|
834
|
+
const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath);
|
|
835
|
+
if (hookUnit) {
|
|
836
|
+
// Dispatch the hook unit instead of normal flow
|
|
837
|
+
const hookStartedAt = Date.now();
|
|
838
|
+
if (currentUnit) {
|
|
839
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
840
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
841
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
842
|
+
}
|
|
843
|
+
currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
|
|
844
|
+
writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
|
|
845
|
+
phase: "dispatched",
|
|
846
|
+
wrapupWarningSent: false,
|
|
847
|
+
timeoutAt: null,
|
|
848
|
+
lastProgressAt: hookStartedAt,
|
|
849
|
+
progressCount: 0,
|
|
850
|
+
lastProgressKind: "dispatch",
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const state = await deriveState(basePath);
|
|
854
|
+
updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state);
|
|
855
|
+
const hookState = getActiveHook();
|
|
856
|
+
ctx.ui.notify(
|
|
857
|
+
`Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`,
|
|
858
|
+
"info",
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
// Switch model if the hook specifies one
|
|
862
|
+
if (hookUnit.model) {
|
|
863
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
864
|
+
const match = availableModels.find(m =>
|
|
865
|
+
m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model,
|
|
866
|
+
);
|
|
867
|
+
if (match) {
|
|
868
|
+
try {
|
|
869
|
+
await pi.setModel(match);
|
|
870
|
+
} catch { /* non-fatal — use current model */ }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const result = await cmdCtx!.newSession();
|
|
875
|
+
if (result.cancelled) {
|
|
876
|
+
resetHookState();
|
|
877
|
+
await stopAuto(ctx, pi);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
881
|
+
writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
|
|
882
|
+
// Persist hook state so cycle counts survive crashes
|
|
883
|
+
persistHookState(basePath);
|
|
884
|
+
pi.sendMessage(
|
|
885
|
+
{ customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
|
|
886
|
+
{ triggerTurn: true },
|
|
887
|
+
);
|
|
888
|
+
return; // handleAgentEnd will fire again when hook session completes
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Check if a hook requested a retry of the trigger unit
|
|
892
|
+
if (isRetryPending()) {
|
|
893
|
+
const trigger = consumeRetryTrigger();
|
|
894
|
+
if (trigger) {
|
|
895
|
+
ctx.ui.notify(
|
|
896
|
+
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
|
|
897
|
+
"info",
|
|
898
|
+
);
|
|
899
|
+
// Fall through to normal dispatchNextUnit — state derivation will
|
|
900
|
+
// re-select the same unit since it hasn't been marked complete
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// In step mode, pause and show a wizard instead of immediately dispatching
|
|
906
|
+
if (stepMode) {
|
|
907
|
+
await showStepWizard(ctx, pi);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
await dispatchNextUnit(ctx, pi);
|
|
913
|
+
} catch (dispatchErr) {
|
|
914
|
+
// dispatchNextUnit threw — without this catch the error would propagate
|
|
915
|
+
// to the pi event emitter which may silently swallow async rejections,
|
|
916
|
+
// leaving auto-mode active but permanently stalled (see #381).
|
|
917
|
+
const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
|
|
918
|
+
ctx.ui.notify(
|
|
919
|
+
`Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`,
|
|
920
|
+
"error",
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// Start the dispatch gap watchdog to retry after a delay.
|
|
924
|
+
// This gives transient issues (dirty working tree, branch state) time to settle.
|
|
925
|
+
startDispatchGapWatchdog(ctx, pi);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// If dispatchNextUnit returned normally but auto-mode is still active and
|
|
930
|
+
// no new unit timeout was set (meaning sendMessage was never called), start
|
|
931
|
+
// the dispatch gap watchdog as a safety net.
|
|
932
|
+
if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
|
|
933
|
+
startDispatchGapWatchdog(ctx, pi);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
} finally {
|
|
937
|
+
_handlingAgentEnd = false;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ─── Step Mode Wizard ─────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Show the step-mode wizard after a unit completes.
|
|
945
|
+
* Derives the next unit from disk state and presents it to the user.
|
|
946
|
+
* If the user confirms, dispatches the next unit. If not, pauses.
|
|
947
|
+
*/
|
|
948
|
+
async function showStepWizard(
|
|
949
|
+
ctx: ExtensionContext,
|
|
950
|
+
pi: ExtensionAPI,
|
|
951
|
+
): Promise<void> {
|
|
952
|
+
if (!cmdCtx) return;
|
|
953
|
+
|
|
954
|
+
const state = await deriveState(basePath);
|
|
955
|
+
const mid = state.activeMilestone?.id;
|
|
956
|
+
|
|
957
|
+
// Build summary of what just completed
|
|
958
|
+
const justFinished = currentUnit
|
|
959
|
+
? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
|
|
960
|
+
: "previous unit";
|
|
961
|
+
|
|
962
|
+
// If no active milestone or everything is complete, stop
|
|
963
|
+
if (!mid || state.phase === "complete") {
|
|
964
|
+
await stopAuto(ctx, pi);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Peek at what's next by examining state
|
|
969
|
+
const nextDesc = describeNextUnit(state);
|
|
970
|
+
|
|
971
|
+
const choice = await showNextAction(cmdCtx, {
|
|
972
|
+
title: `GSD — ${justFinished} complete`,
|
|
973
|
+
summary: [
|
|
974
|
+
`${mid}: ${state.activeMilestone?.title ?? mid}`,
|
|
975
|
+
...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []),
|
|
976
|
+
],
|
|
977
|
+
actions: [
|
|
978
|
+
{
|
|
979
|
+
id: "continue",
|
|
980
|
+
label: nextDesc.label,
|
|
981
|
+
description: nextDesc.description,
|
|
982
|
+
recommended: true,
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
id: "auto",
|
|
986
|
+
label: "Switch to auto",
|
|
987
|
+
description: "Continue without pausing between steps.",
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
id: "status",
|
|
991
|
+
label: "View status",
|
|
992
|
+
description: "Open the dashboard.",
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
notYetMessage: "Run /gsd next when ready to continue.",
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
if (choice === "continue") {
|
|
999
|
+
await dispatchNextUnit(ctx, pi);
|
|
1000
|
+
} else if (choice === "auto") {
|
|
1001
|
+
stepMode = false;
|
|
1002
|
+
ctx.ui.setStatus("gsd-auto", "auto");
|
|
1003
|
+
ctx.ui.notify("Switched to auto-mode.", "info");
|
|
1004
|
+
await dispatchNextUnit(ctx, pi);
|
|
1005
|
+
} else if (choice === "status") {
|
|
1006
|
+
// Show status then re-show the wizard
|
|
1007
|
+
const { fireStatusViaCommand } = await import("./commands.js");
|
|
1008
|
+
await fireStatusViaCommand(ctx as ExtensionCommandContext);
|
|
1009
|
+
await showStepWizard(ctx, pi);
|
|
1010
|
+
} else {
|
|
1011
|
+
// "not_yet" — pause
|
|
1012
|
+
await pauseAuto(ctx, pi);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Describe what the next unit will be, based on current state.
|
|
1018
|
+
*/
|
|
1019
|
+
export function describeNextUnit(state: GSDState): { label: string; description: string } {
|
|
1020
|
+
const sid = state.activeSlice?.id;
|
|
1021
|
+
const sTitle = state.activeSlice?.title;
|
|
1022
|
+
const tid = state.activeTask?.id;
|
|
1023
|
+
const tTitle = state.activeTask?.title;
|
|
1024
|
+
|
|
1025
|
+
switch (state.phase) {
|
|
1026
|
+
case "needs-discussion":
|
|
1027
|
+
return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." };
|
|
1028
|
+
case "pre-planning":
|
|
1029
|
+
return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
|
|
1030
|
+
case "planning":
|
|
1031
|
+
return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." };
|
|
1032
|
+
case "executing":
|
|
1033
|
+
return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." };
|
|
1034
|
+
case "summarizing":
|
|
1035
|
+
return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." };
|
|
1036
|
+
case "replanning-slice":
|
|
1037
|
+
return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
|
|
1038
|
+
case "completing-milestone":
|
|
1039
|
+
return { label: "Complete milestone", description: "Write milestone summary." };
|
|
1040
|
+
default:
|
|
1041
|
+
return { label: "Continue", description: "Execute the next step." };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ─── Progress Widget ──────────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
function unitVerb(unitType: string): string {
|
|
1048
|
+
if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`;
|
|
1049
|
+
switch (unitType) {
|
|
1050
|
+
case "research-milestone":
|
|
1051
|
+
case "research-slice": return "researching";
|
|
1052
|
+
case "plan-milestone":
|
|
1053
|
+
case "plan-slice": return "planning";
|
|
1054
|
+
case "execute-task": return "executing";
|
|
1055
|
+
case "complete-slice": return "completing";
|
|
1056
|
+
case "replan-slice": return "replanning";
|
|
1057
|
+
case "reassess-roadmap": return "reassessing";
|
|
1058
|
+
case "run-uat": return "running UAT";
|
|
1059
|
+
case "fix-merge": return "resolving conflicts";
|
|
1060
|
+
default: return unitType;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function unitPhaseLabel(unitType: string): string {
|
|
1065
|
+
if (unitType.startsWith("hook/")) return "HOOK";
|
|
1066
|
+
switch (unitType) {
|
|
1067
|
+
case "research-milestone": return "RESEARCH";
|
|
1068
|
+
case "research-slice": return "RESEARCH";
|
|
1069
|
+
case "plan-milestone": return "PLAN";
|
|
1070
|
+
case "plan-slice": return "PLAN";
|
|
1071
|
+
case "execute-task": return "EXECUTE";
|
|
1072
|
+
case "complete-slice": return "COMPLETE";
|
|
1073
|
+
case "replan-slice": return "REPLAN";
|
|
1074
|
+
case "reassess-roadmap": return "REASSESS";
|
|
1075
|
+
case "run-uat": return "UAT";
|
|
1076
|
+
case "fix-merge": return "MERGE-FIX";
|
|
1077
|
+
default: return unitType.toUpperCase();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function peekNext(unitType: string, state: GSDState): string {
|
|
1082
|
+
// Show active hook info in progress display
|
|
1083
|
+
const activeHookState = getActiveHook();
|
|
1084
|
+
if (activeHookState) {
|
|
1085
|
+
return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const sid = state.activeSlice?.id ?? "";
|
|
1089
|
+
if (unitType.startsWith("hook/")) return `continue ${sid}`;
|
|
1090
|
+
switch (unitType) {
|
|
1091
|
+
case "research-milestone": return "plan milestone roadmap";
|
|
1092
|
+
case "plan-milestone": return "plan or execute first slice";
|
|
1093
|
+
case "research-slice": return `plan ${sid}`;
|
|
1094
|
+
case "plan-slice": return "execute first task";
|
|
1095
|
+
case "execute-task": return `continue ${sid}`;
|
|
1096
|
+
case "complete-slice": return "reassess roadmap";
|
|
1097
|
+
case "replan-slice": return `re-execute ${sid}`;
|
|
1098
|
+
case "reassess-roadmap": return "advance to next slice";
|
|
1099
|
+
case "run-uat": return "reassess roadmap";
|
|
1100
|
+
case "fix-merge": return "continue merge";
|
|
1101
|
+
default: return "";
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
/** Right-align helper: build a line with left content and right content. */
|
|
1108
|
+
function rightAlign(left: string, right: string, width: number): string {
|
|
1109
|
+
const leftVis = visibleWidth(left);
|
|
1110
|
+
const rightVis = visibleWidth(right);
|
|
1111
|
+
const gap = Math.max(1, width - leftVis - rightVis);
|
|
1112
|
+
return truncateToWidth(left + " ".repeat(gap) + right, width);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function updateProgressWidget(
|
|
1116
|
+
ctx: ExtensionContext,
|
|
1117
|
+
unitType: string,
|
|
1118
|
+
unitId: string,
|
|
1119
|
+
state: GSDState,
|
|
1120
|
+
): void {
|
|
1121
|
+
if (!ctx.hasUI) return;
|
|
1122
|
+
|
|
1123
|
+
const verb = unitVerb(unitType);
|
|
1124
|
+
const phaseLabel = unitPhaseLabel(unitType);
|
|
1125
|
+
const mid = state.activeMilestone;
|
|
1126
|
+
const slice = state.activeSlice;
|
|
1127
|
+
const task = state.activeTask;
|
|
1128
|
+
const next = peekNext(unitType, state);
|
|
1129
|
+
|
|
1130
|
+
// Cache git branch at widget creation time (not per render)
|
|
1131
|
+
let cachedBranch: string | null = null;
|
|
1132
|
+
try { cachedBranch = getCurrentBranch(basePath); } catch { /* not in git repo */ }
|
|
1133
|
+
|
|
1134
|
+
// Cache pwd with ~ substitution
|
|
1135
|
+
let widgetPwd = process.cwd();
|
|
1136
|
+
const widgetHome = process.env.HOME || process.env.USERPROFILE;
|
|
1137
|
+
if (widgetHome && widgetPwd.startsWith(widgetHome)) {
|
|
1138
|
+
widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
|
|
1139
|
+
}
|
|
1140
|
+
if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
|
|
1141
|
+
|
|
1142
|
+
ctx.ui.setWidget("gsd-progress", (tui, theme) => {
|
|
1143
|
+
let pulseBright = true;
|
|
1144
|
+
let cachedLines: string[] | undefined;
|
|
1145
|
+
let cachedWidth: number | undefined;
|
|
1146
|
+
|
|
1147
|
+
const pulseTimer = setInterval(() => {
|
|
1148
|
+
pulseBright = !pulseBright;
|
|
1149
|
+
cachedLines = undefined;
|
|
1150
|
+
tui.requestRender();
|
|
1151
|
+
}, 800);
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
render(width: number): string[] {
|
|
1155
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
1156
|
+
|
|
1157
|
+
const ui = makeUI(theme, width);
|
|
1158
|
+
const lines: string[] = [];
|
|
1159
|
+
const pad = INDENT.base;
|
|
1160
|
+
|
|
1161
|
+
// ── Line 1: Top bar ───────────────────────────────────────────────
|
|
1162
|
+
lines.push(...ui.bar());
|
|
1163
|
+
|
|
1164
|
+
const dot = pulseBright
|
|
1165
|
+
? theme.fg("accent", GLYPH.statusActive)
|
|
1166
|
+
: theme.fg("dim", GLYPH.statusPending);
|
|
1167
|
+
const elapsed = formatAutoElapsed();
|
|
1168
|
+
const modeTag = stepMode ? "NEXT" : "AUTO";
|
|
1169
|
+
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
|
|
1170
|
+
const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
|
|
1171
|
+
lines.push(rightAlign(headerLeft, headerRight, width));
|
|
1172
|
+
|
|
1173
|
+
lines.push("");
|
|
1174
|
+
|
|
1175
|
+
if (mid) {
|
|
1176
|
+
lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
|
|
1180
|
+
lines.push(truncateToWidth(
|
|
1181
|
+
`${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
|
|
1182
|
+
width,
|
|
1183
|
+
));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
lines.push("");
|
|
1187
|
+
|
|
1188
|
+
const target = task ? `${task.id}: ${task.title}` : unitId;
|
|
1189
|
+
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
|
1190
|
+
const phaseBadge = theme.fg("dim", phaseLabel);
|
|
1191
|
+
lines.push(rightAlign(actionLeft, phaseBadge, width));
|
|
1192
|
+
lines.push("");
|
|
1193
|
+
|
|
1194
|
+
if (mid) {
|
|
1195
|
+
const roadmapSlices = getRoadmapSlicesSync();
|
|
1196
|
+
if (roadmapSlices) {
|
|
1197
|
+
const { done, total, activeSliceTasks } = roadmapSlices;
|
|
1198
|
+
const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3)));
|
|
1199
|
+
const pct = total > 0 ? done / total : 0;
|
|
1200
|
+
const filled = Math.round(pct * barWidth);
|
|
1201
|
+
const bar = theme.fg("success", "█".repeat(filled))
|
|
1202
|
+
+ theme.fg("dim", "░".repeat(barWidth - filled));
|
|
1203
|
+
|
|
1204
|
+
let meta = theme.fg("dim", `${done}/${total} slices`);
|
|
1205
|
+
|
|
1206
|
+
if (activeSliceTasks && activeSliceTasks.total > 0) {
|
|
1207
|
+
meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
lines.push("");
|
|
1215
|
+
|
|
1216
|
+
if (next) {
|
|
1217
|
+
lines.push(truncateToWidth(
|
|
1218
|
+
`${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`,
|
|
1219
|
+
width,
|
|
1220
|
+
));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ── Footer info (pwd, tokens, cost, context, model) ──────────────
|
|
1224
|
+
lines.push("");
|
|
1225
|
+
lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
|
|
1226
|
+
|
|
1227
|
+
// Token stats from current unit session + cumulative cost from metrics
|
|
1228
|
+
{
|
|
1229
|
+
let totalInput = 0, totalOutput = 0;
|
|
1230
|
+
let totalCacheRead = 0, totalCacheWrite = 0;
|
|
1231
|
+
if (cmdCtx) {
|
|
1232
|
+
for (const entry of cmdCtx.sessionManager.getEntries()) {
|
|
1233
|
+
if (entry.type === "message" && (entry as any).message?.role === "assistant") {
|
|
1234
|
+
const u = (entry as any).message.usage;
|
|
1235
|
+
if (u) {
|
|
1236
|
+
totalInput += u.input || 0;
|
|
1237
|
+
totalOutput += u.output || 0;
|
|
1238
|
+
totalCacheRead += u.cacheRead || 0;
|
|
1239
|
+
totalCacheWrite += u.cacheWrite || 0;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const mLedger = getLedger();
|
|
1245
|
+
const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
|
|
1246
|
+
const cumulativeCost = autoTotals?.cost ?? 0;
|
|
1247
|
+
|
|
1248
|
+
const cxUsage = cmdCtx?.getContextUsage?.();
|
|
1249
|
+
const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
|
|
1250
|
+
const cxPctVal = cxUsage?.percent ?? 0;
|
|
1251
|
+
const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
|
|
1252
|
+
|
|
1253
|
+
const sp: string[] = [];
|
|
1254
|
+
if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`);
|
|
1255
|
+
if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`);
|
|
1256
|
+
if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
|
|
1257
|
+
if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
|
|
1258
|
+
if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
|
|
1259
|
+
|
|
1260
|
+
const cxDisplay = cxPct === "?"
|
|
1261
|
+
? `?/${formatWidgetTokens(cxWindow)}`
|
|
1262
|
+
: `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
|
|
1263
|
+
if (cxPctVal > 90) {
|
|
1264
|
+
sp.push(theme.fg("error", cxDisplay));
|
|
1265
|
+
} else if (cxPctVal > 70) {
|
|
1266
|
+
sp.push(theme.fg("warning", cxDisplay));
|
|
1267
|
+
} else {
|
|
1268
|
+
sp.push(cxDisplay);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
|
|
1272
|
+
.join(theme.fg("dim", " "));
|
|
1273
|
+
|
|
1274
|
+
const modelId = cmdCtx?.model?.id ?? "";
|
|
1275
|
+
const modelProvider = cmdCtx?.model?.provider ?? "";
|
|
1276
|
+
const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : "";
|
|
1277
|
+
const modelDisplay = modelProvider && modelId
|
|
1278
|
+
? `${modelProvider}/${modelId}`
|
|
1279
|
+
: modelId;
|
|
1280
|
+
const sRight = modelDisplay
|
|
1281
|
+
? `${modelPhase}${theme.fg("dim", modelDisplay)}`
|
|
1282
|
+
: "";
|
|
1283
|
+
lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const hintParts: string[] = [];
|
|
1287
|
+
hintParts.push("esc pause");
|
|
1288
|
+
hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard");
|
|
1289
|
+
lines.push(...ui.hints(hintParts));
|
|
1290
|
+
|
|
1291
|
+
lines.push(...ui.bar());
|
|
1292
|
+
|
|
1293
|
+
cachedLines = lines;
|
|
1294
|
+
cachedWidth = width;
|
|
1295
|
+
return lines;
|
|
1296
|
+
},
|
|
1297
|
+
invalidate() {
|
|
1298
|
+
cachedLines = undefined;
|
|
1299
|
+
cachedWidth = undefined;
|
|
1300
|
+
},
|
|
1301
|
+
dispose() {
|
|
1302
|
+
clearInterval(pulseTimer);
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** Format elapsed time since auto-mode started */
|
|
1309
|
+
function formatAutoElapsed(): string {
|
|
1310
|
+
if (!autoStartTime) return "";
|
|
1311
|
+
const ms = Date.now() - autoStartTime;
|
|
1312
|
+
const s = Math.floor(ms / 1000);
|
|
1313
|
+
if (s < 60) return `${s}s`;
|
|
1314
|
+
const m = Math.floor(s / 60);
|
|
1315
|
+
const rs = s % 60;
|
|
1316
|
+
if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`;
|
|
1317
|
+
const h = Math.floor(m / 60);
|
|
1318
|
+
const rm = m % 60;
|
|
1319
|
+
return `${h}h ${rm}m`;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Cached slice progress for the widget — avoid async in render */
|
|
1323
|
+
let cachedSliceProgress: {
|
|
1324
|
+
done: number;
|
|
1325
|
+
total: number;
|
|
1326
|
+
milestoneId: string;
|
|
1327
|
+
/** Real task progress for the active slice, if its plan file exists */
|
|
1328
|
+
activeSliceTasks: { done: number; total: number } | null;
|
|
1329
|
+
} | null = null;
|
|
1330
|
+
|
|
1331
|
+
function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void {
|
|
1332
|
+
try {
|
|
1333
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
1334
|
+
if (!roadmapFile) return;
|
|
1335
|
+
const content = readFileSync(roadmapFile, "utf-8");
|
|
1336
|
+
const roadmap = parseRoadmap(content);
|
|
1337
|
+
|
|
1338
|
+
let activeSliceTasks: { done: number; total: number } | null = null;
|
|
1339
|
+
if (activeSid) {
|
|
1340
|
+
try {
|
|
1341
|
+
const planFile = resolveSliceFile(base, mid, activeSid, "PLAN");
|
|
1342
|
+
if (planFile && existsSync(planFile)) {
|
|
1343
|
+
const planContent = readFileSync(planFile, "utf-8");
|
|
1344
|
+
const plan = parsePlan(planContent);
|
|
1345
|
+
activeSliceTasks = {
|
|
1346
|
+
done: plan.tasks.filter(t => t.done).length,
|
|
1347
|
+
total: plan.tasks.length,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
} catch {
|
|
1351
|
+
// Non-fatal — just omit task count
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
cachedSliceProgress = {
|
|
1356
|
+
done: roadmap.slices.filter(s => s.done).length,
|
|
1357
|
+
total: roadmap.slices.length,
|
|
1358
|
+
milestoneId: mid,
|
|
1359
|
+
activeSliceTasks,
|
|
1360
|
+
};
|
|
1361
|
+
} catch {
|
|
1362
|
+
// Non-fatal — widget just won't show progress bar
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null {
|
|
1367
|
+
return cachedSliceProgress;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// ─── Core Loop ────────────────────────────────────────────────────────────────
|
|
1371
|
+
|
|
1372
|
+
async function dispatchNextUnit(
|
|
1373
|
+
ctx: ExtensionContext,
|
|
1374
|
+
pi: ExtensionAPI,
|
|
1375
|
+
): Promise<void> {
|
|
1376
|
+
if (!active || !cmdCtx) {
|
|
1377
|
+
if (active && !cmdCtx) {
|
|
1378
|
+
ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error");
|
|
1379
|
+
}
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Clear stale directory listing cache so deriveState sees fresh disk state (#431)
|
|
1384
|
+
clearPathCache();
|
|
1385
|
+
|
|
1386
|
+
let state = await deriveState(basePath);
|
|
1387
|
+
let mid = state.activeMilestone?.id;
|
|
1388
|
+
let midTitle = state.activeMilestone?.title;
|
|
1389
|
+
|
|
1390
|
+
// Detect milestone transition
|
|
1391
|
+
if (mid && currentMilestoneId && mid !== currentMilestoneId) {
|
|
1392
|
+
ctx.ui.notify(
|
|
1393
|
+
`Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
|
|
1394
|
+
"info",
|
|
1395
|
+
);
|
|
1396
|
+
// Reset stuck detection for new milestone
|
|
1397
|
+
unitDispatchCount.clear();
|
|
1398
|
+
unitRecoveryCount.clear();
|
|
1399
|
+
// Capture integration branch for the new milestone and update git service
|
|
1400
|
+
captureIntegrationBranch(basePath, mid);
|
|
1401
|
+
}
|
|
1402
|
+
if (mid) {
|
|
1403
|
+
currentMilestoneId = mid;
|
|
1404
|
+
setActiveMilestoneId(basePath, mid);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (!mid) {
|
|
1408
|
+
// Save final session before stopping
|
|
1409
|
+
if (currentUnit) {
|
|
1410
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1411
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1412
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1413
|
+
}
|
|
1414
|
+
await stopAuto(ctx, pi);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Guard: mid/midTitle must be defined strings from this point onward.
|
|
1419
|
+
// The !mid check above returns early if mid is falsy; midTitle comes from
|
|
1420
|
+
// the same object so it should always be present when mid is.
|
|
1421
|
+
if (!midTitle) {
|
|
1422
|
+
await stopAuto(ctx, pi);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ── Mid-merge safety check: detect leftover state from a prior fix-merge session ──
|
|
1427
|
+
// If MERGE_HEAD or SQUASH_MSG exists, a fix-merge session ran previously.
|
|
1428
|
+
// Check whether it succeeded (no unmerged entries → finalize) or failed (still conflicted → reset + stop).
|
|
1429
|
+
{
|
|
1430
|
+
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
|
|
1431
|
+
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
|
|
1432
|
+
const hasMergeHead = existsSync(mergeHeadPath);
|
|
1433
|
+
const hasSquashMsg = existsSync(squashMsgPath);
|
|
1434
|
+
if (hasMergeHead || hasSquashMsg) {
|
|
1435
|
+
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
|
1436
|
+
if (!unmerged || !unmerged.trim()) {
|
|
1437
|
+
// fix-merge succeeded — finalize the commit if needed (squash or normal merge)
|
|
1438
|
+
if (hasMergeHead || hasSquashMsg) {
|
|
1439
|
+
try {
|
|
1440
|
+
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
|
|
1441
|
+
const mode = hasMergeHead ? "merge" : "squash commit";
|
|
1442
|
+
ctx.ui.notify(`Fix-merge session succeeded — finalized ${mode}.`, "info");
|
|
1443
|
+
} catch {
|
|
1444
|
+
// Commit may already exist; non-fatal
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
// Re-derive state from the now-merged working tree
|
|
1448
|
+
invalidateStateCache();
|
|
1449
|
+
clearParseCache();
|
|
1450
|
+
clearPathCache();
|
|
1451
|
+
state = await deriveState(basePath);
|
|
1452
|
+
mid = state.activeMilestone?.id;
|
|
1453
|
+
midTitle = state.activeMilestone?.title;
|
|
1454
|
+
} else {
|
|
1455
|
+
// fix-merge failed — still has unresolved conflicts, abort merge/squash, reset and stop
|
|
1456
|
+
if (hasMergeHead) {
|
|
1457
|
+
// Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared
|
|
1458
|
+
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
|
|
1459
|
+
} else if (hasSquashMsg) {
|
|
1460
|
+
// Squash-in-progress without MERGE_HEAD: remove stale squash metadata
|
|
1461
|
+
try {
|
|
1462
|
+
unlinkSync(squashMsgPath);
|
|
1463
|
+
} catch {
|
|
1464
|
+
// Best-effort cleanup; ignore failures
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1468
|
+
ctx.ui.notify(
|
|
1469
|
+
"Fix-merge session failed to resolve all conflicts. Working tree reset. Fix conflicts manually and restart.",
|
|
1470
|
+
"error",
|
|
1471
|
+
);
|
|
1472
|
+
if (currentUnit) {
|
|
1473
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1474
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1475
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1476
|
+
}
|
|
1477
|
+
await stopAuto(ctx, pi);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// ── General merge guard: merge completed slice branches before advancing ──
|
|
1484
|
+
// If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
|
|
1485
|
+
// merge to main before dispatching the next unit. This handles:
|
|
1486
|
+
// - Normal complete-slice → merge → reassess flow
|
|
1487
|
+
// - LLM writes summary during task execution, skipping complete-slice
|
|
1488
|
+
// - Doctor post-hook marks everything done, skipping complete-slice
|
|
1489
|
+
// - complete-milestone runs on a slice branch (last slice bypass)
|
|
1490
|
+
{
|
|
1491
|
+
const currentBranch = getCurrentBranch(basePath);
|
|
1492
|
+
const parsedBranch = parseSliceBranch(currentBranch);
|
|
1493
|
+
if (parsedBranch) {
|
|
1494
|
+
const branchMid = parsedBranch.milestoneId;
|
|
1495
|
+
const branchSid = parsedBranch.sliceId;
|
|
1496
|
+
// Check if this slice is marked done in the roadmap
|
|
1497
|
+
const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
|
|
1498
|
+
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
1499
|
+
if (roadmapContent) {
|
|
1500
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
1501
|
+
const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
|
|
1502
|
+
if (sliceEntry?.done) {
|
|
1503
|
+
try {
|
|
1504
|
+
const sliceTitleForMerge = sliceEntry.title || branchSid;
|
|
1505
|
+
switchToMain(basePath);
|
|
1506
|
+
const mergeResult = mergeSliceToMain(
|
|
1507
|
+
basePath, branchMid, branchSid, sliceTitleForMerge,
|
|
1508
|
+
);
|
|
1509
|
+
const targetBranch = getMainBranch(basePath);
|
|
1510
|
+
ctx.ui.notify(
|
|
1511
|
+
`Merged ${mergeResult.branch} → ${targetBranch}.`,
|
|
1512
|
+
"info",
|
|
1513
|
+
);
|
|
1514
|
+
// Re-derive state from main so downstream logic sees merged state
|
|
1515
|
+
invalidateStateCache();
|
|
1516
|
+
clearParseCache();
|
|
1517
|
+
clearPathCache();
|
|
1518
|
+
state = await deriveState(basePath);
|
|
1519
|
+
mid = state.activeMilestone?.id;
|
|
1520
|
+
midTitle = state.activeMilestone?.title;
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
// MergeConflictError: dispatch a fix-merge session to resolve conflicts
|
|
1523
|
+
if (error instanceof MergeConflictError) {
|
|
1524
|
+
const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
|
|
1525
|
+
const fixMergePrompt = buildFixMergePrompt(error);
|
|
1526
|
+
ctx.ui.notify(
|
|
1527
|
+
`Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
|
|
1528
|
+
"warning",
|
|
1529
|
+
);
|
|
1530
|
+
|
|
1531
|
+
// Close out the previously active unit before overwriting currentUnit.
|
|
1532
|
+
if (currentUnit) {
|
|
1533
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1534
|
+
snapshotUnitMetrics(
|
|
1535
|
+
ctx,
|
|
1536
|
+
currentUnit.type,
|
|
1537
|
+
currentUnit.id,
|
|
1538
|
+
currentUnit.startedAt,
|
|
1539
|
+
modelId,
|
|
1540
|
+
);
|
|
1541
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Dispatch fix-merge as the next unit (early-dispatch-and-return)
|
|
1545
|
+
const fixMergeUnitType = "fix-merge";
|
|
1546
|
+
currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
|
|
1547
|
+
writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
|
|
1548
|
+
phase: "dispatched",
|
|
1549
|
+
wrapupWarningSent: false,
|
|
1550
|
+
timeoutAt: null,
|
|
1551
|
+
lastProgressAt: currentUnit.startedAt,
|
|
1552
|
+
progressCount: 0,
|
|
1553
|
+
lastProgressKind: "dispatch",
|
|
1554
|
+
});
|
|
1555
|
+
updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
|
|
1556
|
+
const result = await cmdCtx!.newSession();
|
|
1557
|
+
if (result.cancelled) {
|
|
1558
|
+
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1559
|
+
await stopAuto(ctx, pi);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1563
|
+
writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
|
|
1564
|
+
pi.sendMessage(
|
|
1565
|
+
{ customType: "gsd-auto", content: fixMergePrompt, display: verbose },
|
|
1566
|
+
{ triggerTurn: true },
|
|
1567
|
+
);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Non-conflict errors: reset and stop
|
|
1572
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1573
|
+
try {
|
|
1574
|
+
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
|
|
1575
|
+
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
|
|
1576
|
+
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1577
|
+
ctx.ui.notify(
|
|
1578
|
+
`Cleaned up conflicted merge state after failed squash-merge.`,
|
|
1579
|
+
"warning",
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
} catch { /* best-effort cleanup */ }
|
|
1583
|
+
|
|
1584
|
+
ctx.ui.notify(
|
|
1585
|
+
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
|
1586
|
+
"error",
|
|
1587
|
+
);
|
|
1588
|
+
if (currentUnit) {
|
|
1589
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1590
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1591
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1592
|
+
}
|
|
1593
|
+
await stopAuto(ctx, pi);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// After merge, mid/midTitle may have been re-derived and could be undefined
|
|
1602
|
+
if (!mid || !midTitle) {
|
|
1603
|
+
if (currentUnit) {
|
|
1604
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1605
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1606
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1607
|
+
}
|
|
1608
|
+
await stopAuto(ctx, pi);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Determine next unit
|
|
1613
|
+
let unitType: string;
|
|
1614
|
+
let unitId: string;
|
|
1615
|
+
let prompt: string;
|
|
1616
|
+
|
|
1617
|
+
if (state.phase === "complete") {
|
|
1618
|
+
if (currentUnit) {
|
|
1619
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1620
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1621
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1622
|
+
}
|
|
1623
|
+
// Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
|
|
1624
|
+
try {
|
|
1625
|
+
const file = completedKeysPath(basePath);
|
|
1626
|
+
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
|
|
1627
|
+
completedKeySet.clear();
|
|
1628
|
+
} catch { /* non-fatal */ }
|
|
1629
|
+
await stopAuto(ctx, pi);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (state.phase === "blocked") {
|
|
1634
|
+
if (currentUnit) {
|
|
1635
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1636
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1637
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1638
|
+
}
|
|
1639
|
+
await stopAuto(ctx, pi);
|
|
1640
|
+
ctx.ui.notify(`Blocked: ${state.blockers.join(", ")}. Fix and run /gsd auto.`, "warning");
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// ── UAT Dispatch: run-uat fires after complete-slice merge, before reassessment ──
|
|
1645
|
+
// Ensures the UAT file and slice summary are both on main when UAT runs.
|
|
1646
|
+
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
|
1647
|
+
|
|
1648
|
+
// Budget ceiling guard — pause before starting next unit if ceiling is hit
|
|
1649
|
+
const budgetCeiling = prefs?.budget_ceiling;
|
|
1650
|
+
if (budgetCeiling !== undefined) {
|
|
1651
|
+
const currentLedger = getLedger();
|
|
1652
|
+
const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
|
|
1653
|
+
if (totalCost >= budgetCeiling) {
|
|
1654
|
+
ctx.ui.notify(
|
|
1655
|
+
`Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}). Pausing auto-mode — /gsd auto to continue.`,
|
|
1656
|
+
"warning",
|
|
1657
|
+
);
|
|
1658
|
+
await pauseAuto(ctx, pi);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ── Secrets re-check gate — runs before every dispatch, not just at startAuto ──
|
|
1664
|
+
// plan-milestone writes the milestone SECRETS file (e.g., M001-SECRETS.md) during its unit. By the time we
|
|
1665
|
+
// reach the next dispatchNextUnit call the manifest exists but hasn't been
|
|
1666
|
+
// presented to the user yet. Without this re-check the model would proceed
|
|
1667
|
+
// into plan-slice / execute-task with no real credentials and mock everything.
|
|
1668
|
+
const runSecretsGate = async () => {
|
|
1669
|
+
try {
|
|
1670
|
+
const manifestStatus = await getManifestStatus(basePath, mid);
|
|
1671
|
+
if (manifestStatus && manifestStatus.pending.length > 0) {
|
|
1672
|
+
const result = await collectSecretsFromManifest(basePath, mid, ctx);
|
|
1673
|
+
if (result && result.applied && result.skipped && result.existingSkipped) {
|
|
1674
|
+
ctx.ui.notify(
|
|
1675
|
+
`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
|
|
1676
|
+
"info",
|
|
1677
|
+
);
|
|
1678
|
+
} else {
|
|
1679
|
+
ctx.ui.notify("Secrets collection skipped.", "info");
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
} catch (err) {
|
|
1683
|
+
ctx.ui.notify(
|
|
1684
|
+
`Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
|
|
1685
|
+
"warning",
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
await runSecretsGate();
|
|
1691
|
+
|
|
1692
|
+
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
|
1693
|
+
// Flag: for human/mixed UAT, pause auto-mode after the prompt is sent so the user
|
|
1694
|
+
// can perform the UAT manually. On next resume, result file will exist → skip.
|
|
1695
|
+
let pauseAfterUatDispatch = false;
|
|
1696
|
+
|
|
1697
|
+
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
|
|
1698
|
+
// If the current phase is "summarizing", complete-slice is responsible for
|
|
1699
|
+
// mergeSliceToMain. Reassessment must wait until the merge is done.
|
|
1700
|
+
if (state.phase === "summarizing") {
|
|
1701
|
+
const sid = state.activeSlice!.id;
|
|
1702
|
+
const sTitle = state.activeSlice!.title;
|
|
1703
|
+
unitType = "complete-slice";
|
|
1704
|
+
unitId = `${mid}/${sid}`;
|
|
1705
|
+
prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1706
|
+
} else {
|
|
1707
|
+
// ── Adaptive Replanning: check if last completed slice needs reassessment ──
|
|
1708
|
+
// Computed here (after summarizing guard) so complete-slice always runs first.
|
|
1709
|
+
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
|
|
1710
|
+
if (needsRunUat) {
|
|
1711
|
+
const { sliceId, uatType } = needsRunUat;
|
|
1712
|
+
unitType = "run-uat";
|
|
1713
|
+
unitId = `${mid}/${sliceId}`;
|
|
1714
|
+
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
|
1715
|
+
const uatContent = await loadFile(uatFile);
|
|
1716
|
+
prompt = await buildRunUatPrompt(
|
|
1717
|
+
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
|
1718
|
+
);
|
|
1719
|
+
// For non-artifact-driven UAT types, pause after the prompt is dispatched.
|
|
1720
|
+
// The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
|
|
1721
|
+
// then auto-mode pauses for human execution. On resume, result file exists → skip.
|
|
1722
|
+
if (uatType !== "artifact-driven") {
|
|
1723
|
+
pauseAfterUatDispatch = true;
|
|
1724
|
+
}
|
|
1725
|
+
} else if (needsReassess) {
|
|
1726
|
+
unitType = "reassess-roadmap";
|
|
1727
|
+
unitId = `${mid}/${needsReassess.sliceId}`;
|
|
1728
|
+
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
|
1729
|
+
} else if (state.phase === "needs-discussion") {
|
|
1730
|
+
// Draft milestone — pause auto-mode and notify user.
|
|
1731
|
+
// This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
|
|
1732
|
+
// where the user chose "Needs own discussion". Auto-mode cannot proceed because
|
|
1733
|
+
// the draft is seed material, not a finalized context — planning requires a
|
|
1734
|
+
// dedicated discussion first.
|
|
1735
|
+
await stopAuto(ctx, pi);
|
|
1736
|
+
ctx.ui.notify(
|
|
1737
|
+
`${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
|
|
1738
|
+
"warning",
|
|
1739
|
+
);
|
|
1740
|
+
return;
|
|
1741
|
+
|
|
1742
|
+
} else if (state.phase === "pre-planning") {
|
|
1743
|
+
// Need roadmap — check if context exists
|
|
1744
|
+
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
|
1745
|
+
const hasContext = !!(contextFile && await loadFile(contextFile));
|
|
1746
|
+
|
|
1747
|
+
if (!hasContext) {
|
|
1748
|
+
await stopAuto(ctx, pi);
|
|
1749
|
+
ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Research before roadmap if no research exists
|
|
1754
|
+
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
|
1755
|
+
const hasResearch = !!researchFile;
|
|
1756
|
+
|
|
1757
|
+
if (!hasResearch) {
|
|
1758
|
+
unitType = "research-milestone";
|
|
1759
|
+
unitId = mid;
|
|
1760
|
+
prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
|
|
1761
|
+
} else {
|
|
1762
|
+
unitType = "plan-milestone";
|
|
1763
|
+
unitId = mid;
|
|
1764
|
+
prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
} else if (state.phase === "planning") {
|
|
1768
|
+
// Slice needs planning — but research first if no research exists
|
|
1769
|
+
const sid = state.activeSlice!.id;
|
|
1770
|
+
const sTitle = state.activeSlice!.title;
|
|
1771
|
+
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
|
1772
|
+
const hasResearch = !!researchFile;
|
|
1773
|
+
|
|
1774
|
+
if (!hasResearch) {
|
|
1775
|
+
// Skip slice research for S01 when milestone research already exists —
|
|
1776
|
+
// the milestone research already covers the same ground for the first slice.
|
|
1777
|
+
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
|
1778
|
+
const hasMilestoneResearch = !!milestoneResearchFile;
|
|
1779
|
+
if (hasMilestoneResearch && sid === "S01") {
|
|
1780
|
+
unitType = "plan-slice";
|
|
1781
|
+
unitId = `${mid}/${sid}`;
|
|
1782
|
+
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1783
|
+
} else {
|
|
1784
|
+
unitType = "research-slice";
|
|
1785
|
+
unitId = `${mid}/${sid}`;
|
|
1786
|
+
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1787
|
+
}
|
|
1788
|
+
} else {
|
|
1789
|
+
unitType = "plan-slice";
|
|
1790
|
+
unitId = `${mid}/${sid}`;
|
|
1791
|
+
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
} else if (state.phase === "replanning-slice") {
|
|
1795
|
+
// Blocker discovered — replan the slice before continuing
|
|
1796
|
+
const sid = state.activeSlice!.id;
|
|
1797
|
+
const sTitle = state.activeSlice!.title;
|
|
1798
|
+
unitType = "replan-slice";
|
|
1799
|
+
unitId = `${mid}/${sid}`;
|
|
1800
|
+
prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
|
1801
|
+
|
|
1802
|
+
} else if (state.phase === "executing" && state.activeTask) {
|
|
1803
|
+
// Execute next task
|
|
1804
|
+
const sid = state.activeSlice!.id;
|
|
1805
|
+
const sTitle = state.activeSlice!.title;
|
|
1806
|
+
const tid = state.activeTask.id;
|
|
1807
|
+
const tTitle = state.activeTask.title;
|
|
1808
|
+
unitType = "execute-task";
|
|
1809
|
+
unitId = `${mid}/${sid}/${tid}`;
|
|
1810
|
+
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
|
|
1811
|
+
|
|
1812
|
+
} else if (state.phase === "completing-milestone") {
|
|
1813
|
+
// All slices done — complete the milestone
|
|
1814
|
+
unitType = "complete-milestone";
|
|
1815
|
+
unitId = mid;
|
|
1816
|
+
prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
|
|
1817
|
+
|
|
1818
|
+
} else {
|
|
1819
|
+
if (currentUnit) {
|
|
1820
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1821
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1822
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1823
|
+
}
|
|
1824
|
+
await stopAuto(ctx, pi);
|
|
1825
|
+
ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
|
|
1831
|
+
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
|
|
1832
|
+
if (preDispatchResult.firedHooks.length > 0) {
|
|
1833
|
+
ctx.ui.notify(
|
|
1834
|
+
`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
|
|
1835
|
+
"info",
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
if (preDispatchResult.action === "skip") {
|
|
1839
|
+
ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
|
|
1840
|
+
// Yield then re-dispatch to advance to next unit
|
|
1841
|
+
await new Promise(r => setImmediate(r));
|
|
1842
|
+
await dispatchNextUnit(ctx, pi);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (preDispatchResult.action === "replace") {
|
|
1846
|
+
prompt = preDispatchResult.prompt ?? prompt;
|
|
1847
|
+
if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
|
|
1848
|
+
} else if (preDispatchResult.prompt) {
|
|
1849
|
+
prompt = preDispatchResult.prompt;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
|
|
1853
|
+
if (priorSliceBlocker) {
|
|
1854
|
+
await stopAuto(ctx, pi);
|
|
1855
|
+
ctx.ui.notify(priorSliceBlocker, "error");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const observabilityIssues = await collectObservabilityWarnings(ctx, unitType, unitId);
|
|
1860
|
+
|
|
1861
|
+
// Idempotency: skip units already completed in a prior session.
|
|
1862
|
+
const idempotencyKey = `${unitType}/${unitId}`;
|
|
1863
|
+
if (completedKeySet.has(idempotencyKey)) {
|
|
1864
|
+
// Cross-validate: does the expected artifact actually exist?
|
|
1865
|
+
const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
|
|
1866
|
+
if (artifactExists) {
|
|
1867
|
+
ctx.ui.notify(
|
|
1868
|
+
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
|
|
1869
|
+
"info",
|
|
1870
|
+
);
|
|
1871
|
+
// Yield to the event loop before re-dispatching to avoid tight recursion
|
|
1872
|
+
// when many units are already completed (e.g., after crash recovery).
|
|
1873
|
+
await new Promise(r => setImmediate(r));
|
|
1874
|
+
await dispatchNextUnit(ctx, pi);
|
|
1875
|
+
return;
|
|
1876
|
+
} else {
|
|
1877
|
+
// Stale completion record — artifact missing. Remove and re-run.
|
|
1878
|
+
completedKeySet.delete(idempotencyKey);
|
|
1879
|
+
removePersistedKey(basePath, idempotencyKey);
|
|
1880
|
+
ctx.ui.notify(
|
|
1881
|
+
`Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
|
|
1882
|
+
"warning",
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
|
|
1888
|
+
// Pattern A→B→A→B would reset retryCount every time; this map catches it.
|
|
1889
|
+
const dispatchKey = `${unitType}/${unitId}`;
|
|
1890
|
+
const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
|
|
1891
|
+
if (prevCount >= MAX_UNIT_DISPATCHES) {
|
|
1892
|
+
if (currentUnit) {
|
|
1893
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1894
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1895
|
+
}
|
|
1896
|
+
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
1897
|
+
|
|
1898
|
+
// Final reconciliation pass for execute-task: write any missing durable
|
|
1899
|
+
// artifacts (summary placeholder + [x] checkbox) so the pipeline can
|
|
1900
|
+
// advance instead of stopping. This is the last resort before halting.
|
|
1901
|
+
if (unitType === "execute-task") {
|
|
1902
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
1903
|
+
if (mid && sid && tid) {
|
|
1904
|
+
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
1905
|
+
if (status) {
|
|
1906
|
+
const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount);
|
|
1907
|
+
// reconciled: skipExecuteTask attempted to write missing artifacts.
|
|
1908
|
+
// verifyExpectedArtifact: confirms physical artifacts (summary + [x]) now exist on disk.
|
|
1909
|
+
// Both must pass before we clear the dispatch counter and advance.
|
|
1910
|
+
if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
1911
|
+
ctx.ui.notify(
|
|
1912
|
+
`Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
|
|
1913
|
+
"warning",
|
|
1914
|
+
);
|
|
1915
|
+
unitDispatchCount.delete(dispatchKey);
|
|
1916
|
+
await new Promise(r => setImmediate(r));
|
|
1917
|
+
await dispatchNextUnit(ctx, pi);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
1925
|
+
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
|
|
1926
|
+
await stopAuto(ctx, pi);
|
|
1927
|
+
ctx.ui.notify(
|
|
1928
|
+
`Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
|
|
1929
|
+
"error",
|
|
1930
|
+
);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
unitDispatchCount.set(dispatchKey, prevCount + 1);
|
|
1934
|
+
if (prevCount > 0) {
|
|
1935
|
+
// Adaptive self-repair: each retry attempts a different remediation step.
|
|
1936
|
+
if (unitType === "execute-task") {
|
|
1937
|
+
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
1938
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
1939
|
+
if (status && mid && sid && tid) {
|
|
1940
|
+
if (status.summaryExists && !status.taskChecked) {
|
|
1941
|
+
// Retry 1+: summary exists but checkbox not marked — mark [x] and advance.
|
|
1942
|
+
const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
|
|
1943
|
+
// repaired: skipExecuteTask updated metadata (returned early-true even if regex missed).
|
|
1944
|
+
// verifyExpectedArtifact: confirms the physical artifact (summary + [x]) now exists.
|
|
1945
|
+
if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
1946
|
+
ctx.ui.notify(
|
|
1947
|
+
`Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
|
|
1948
|
+
"warning",
|
|
1949
|
+
);
|
|
1950
|
+
unitDispatchCount.delete(dispatchKey);
|
|
1951
|
+
await new Promise(r => setImmediate(r));
|
|
1952
|
+
await dispatchNextUnit(ctx, pi);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
} else if (prevCount >= STUB_RECOVERY_THRESHOLD && !status.summaryExists) {
|
|
1956
|
+
// Retry STUB_RECOVERY_THRESHOLD+: summary still missing after multiple attempts.
|
|
1957
|
+
// Write a minimal stub summary so the next agent session has a recovery artifact
|
|
1958
|
+
// to overwrite, rather than starting from scratch again.
|
|
1959
|
+
const tasksDir = resolveTasksDir(basePath, mid, sid);
|
|
1960
|
+
const sDir = resolveSlicePath(basePath, mid, sid);
|
|
1961
|
+
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
|
|
1962
|
+
if (targetDir) {
|
|
1963
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
1964
|
+
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
|
|
1965
|
+
if (!existsSync(summaryPath)) {
|
|
1966
|
+
const stubContent = [
|
|
1967
|
+
`# PARTIAL RECOVERY — attempt ${prevCount + 1} of ${MAX_UNIT_DISPATCHES}`,
|
|
1968
|
+
``,
|
|
1969
|
+
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) has not yet produced a real summary.`,
|
|
1970
|
+
`This placeholder was written by auto-mode after ${prevCount} dispatch attempts.`,
|
|
1971
|
+
``,
|
|
1972
|
+
`The next agent session will retry this task. Replace this file with real work when done.`,
|
|
1973
|
+
].join("\n");
|
|
1974
|
+
writeFileSync(summaryPath, stubContent, "utf-8");
|
|
1975
|
+
ctx.ui.notify(
|
|
1976
|
+
`Stub recovery (attempt ${prevCount + 1}/${MAX_UNIT_DISPATCHES}): ${unitId} stub summary placeholder written. Retrying with recovery context.`,
|
|
1977
|
+
"warning",
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
ctx.ui.notify(
|
|
1985
|
+
`${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`,
|
|
1986
|
+
"warning",
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
// Snapshot metrics + activity log for the PREVIOUS unit before we reassign.
|
|
1990
|
+
// The session still holds the previous unit's data (newSession hasn't fired yet).
|
|
1991
|
+
if (currentUnit) {
|
|
1992
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
1993
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1994
|
+
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1995
|
+
|
|
1996
|
+
// Only mark the previous unit as completed if:
|
|
1997
|
+
// 1. We're not about to re-dispatch the same unit (retry scenario)
|
|
1998
|
+
// 2. The expected artifact actually exists on disk
|
|
1999
|
+
const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
2000
|
+
const incomingKey = `${unitType}/${unitId}`;
|
|
2001
|
+
const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
2002
|
+
if (closeoutKey !== incomingKey && artifactVerified) {
|
|
2003
|
+
persistCompletedKey(basePath, closeoutKey);
|
|
2004
|
+
completedKeySet.add(closeoutKey);
|
|
2005
|
+
|
|
2006
|
+
completedUnits.push({
|
|
2007
|
+
type: currentUnit.type,
|
|
2008
|
+
id: currentUnit.id,
|
|
2009
|
+
startedAt: currentUnit.startedAt,
|
|
2010
|
+
finishedAt: Date.now(),
|
|
2011
|
+
});
|
|
2012
|
+
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
|
2013
|
+
unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
2014
|
+
unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
|
2018
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2019
|
+
phase: "dispatched",
|
|
2020
|
+
wrapupWarningSent: false,
|
|
2021
|
+
timeoutAt: null,
|
|
2022
|
+
lastProgressAt: currentUnit.startedAt,
|
|
2023
|
+
progressCount: 0,
|
|
2024
|
+
lastProgressKind: "dispatch",
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
// Status bar + progress widget
|
|
2028
|
+
ctx.ui.setStatus("gsd-auto", "auto");
|
|
2029
|
+
if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id);
|
|
2030
|
+
updateProgressWidget(ctx, unitType, unitId, state);
|
|
2031
|
+
|
|
2032
|
+
// Ensure preconditions — create directories, branches, etc.
|
|
2033
|
+
// so the LLM doesn't have to get these right
|
|
2034
|
+
ensurePreconditions(unitType, unitId, basePath, state);
|
|
2035
|
+
|
|
2036
|
+
// Fresh session
|
|
2037
|
+
const result = await cmdCtx!.newSession();
|
|
2038
|
+
if (result.cancelled) {
|
|
2039
|
+
await stopAuto(ctx, pi);
|
|
2040
|
+
ctx.ui.notify("New session cancelled — auto-mode stopped.", "warning");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// NOTE: Slice merge happens AFTER the complete-slice unit finishes,
|
|
2045
|
+
// not here at dispatch time. See the merge logic at the top of
|
|
2046
|
+
// dispatchNextUnit where we check if the previous unit was complete-slice.
|
|
2047
|
+
|
|
2048
|
+
// Write lock AFTER newSession so we capture the session file path.
|
|
2049
|
+
// Pi appends entries incrementally via appendFileSync, so on crash the
|
|
2050
|
+
// session file survives with every tool call up to the crash point.
|
|
2051
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
2052
|
+
writeLock(basePath, unitType, unitId, completedUnits.length, sessionFile);
|
|
2053
|
+
|
|
2054
|
+
// On crash recovery, prepend the full recovery briefing
|
|
2055
|
+
// On retry (stuck detection), prepend deep diagnostic from last attempt
|
|
2056
|
+
// Cap injected content to prevent unbounded prompt growth → OOM
|
|
2057
|
+
const MAX_RECOVERY_CHARS = 50_000;
|
|
2058
|
+
let finalPrompt = prompt;
|
|
2059
|
+
if (pendingCrashRecovery) {
|
|
2060
|
+
const capped = pendingCrashRecovery.length > MAX_RECOVERY_CHARS
|
|
2061
|
+
? pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
|
|
2062
|
+
: pendingCrashRecovery;
|
|
2063
|
+
finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
|
|
2064
|
+
pendingCrashRecovery = null;
|
|
2065
|
+
} else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
|
|
2066
|
+
const diagnostic = getDeepDiagnostic(basePath);
|
|
2067
|
+
if (diagnostic) {
|
|
2068
|
+
const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
|
|
2069
|
+
? diagnostic.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...diagnostic truncated to prevent memory exhaustion]"
|
|
2070
|
+
: diagnostic;
|
|
2071
|
+
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}`;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// Inject observability repair instructions so the agent fixes gaps before
|
|
2076
|
+
// proceeding with the unit (see #174).
|
|
2077
|
+
const repairBlock = buildObservabilityRepairBlock(observabilityIssues);
|
|
2078
|
+
if (repairBlock) {
|
|
2079
|
+
finalPrompt = `${finalPrompt}${repairBlock}`;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Switch model if preferences specify one for this unit type
|
|
2083
|
+
// Try primary model, then fallbacks in order if setting fails
|
|
2084
|
+
const modelConfig = resolveModelWithFallbacksForUnit(unitType);
|
|
2085
|
+
if (modelConfig) {
|
|
2086
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
2087
|
+
const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
|
|
2088
|
+
let modelSet = false;
|
|
2089
|
+
|
|
2090
|
+
for (const modelId of modelsToTry) {
|
|
2091
|
+
// Resolve model from available models.
|
|
2092
|
+
// Handles multiple formats:
|
|
2093
|
+
// "provider/model" → explicit provider targeting (e.g. "anthropic/claude-opus-4-6")
|
|
2094
|
+
// "bare-id" → match by ID across providers
|
|
2095
|
+
// "org/model-name" → OpenRouter-style IDs where the full string is the model ID
|
|
2096
|
+
// "openrouter/org/model" → explicit provider + OpenRouter model ID
|
|
2097
|
+
const slashIdx = modelId.indexOf("/");
|
|
2098
|
+
let model;
|
|
2099
|
+
if (slashIdx !== -1) {
|
|
2100
|
+
const maybeProvider = modelId.substring(0, slashIdx);
|
|
2101
|
+
const id = modelId.substring(slashIdx + 1);
|
|
2102
|
+
|
|
2103
|
+
// Check if the prefix before the first slash is a known provider
|
|
2104
|
+
const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
|
|
2105
|
+
if (knownProviders.has(maybeProvider.toLowerCase())) {
|
|
2106
|
+
// Explicit "provider/model" format (handles "openrouter/org/model" too)
|
|
2107
|
+
model = availableModels.find(
|
|
2108
|
+
m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
|
|
2109
|
+
&& m.id.toLowerCase() === id.toLowerCase(),
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// If the prefix wasn't a known provider, or no match was found within that provider,
|
|
2114
|
+
// try matching the full string as a model ID (OpenRouter-style IDs like "org/model-name")
|
|
2115
|
+
if (!model) {
|
|
2116
|
+
const lower = modelId.toLowerCase();
|
|
2117
|
+
model = availableModels.find(
|
|
2118
|
+
m => m.id.toLowerCase() === lower
|
|
2119
|
+
|| `${m.provider}/${m.id}`.toLowerCase() === lower,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
} else {
|
|
2123
|
+
// For bare IDs, prefer the current session's provider, then first available match
|
|
2124
|
+
const currentProvider = ctx.model?.provider;
|
|
2125
|
+
const exactProviderMatch = availableModels.find(
|
|
2126
|
+
m => m.id === modelId && m.provider === currentProvider,
|
|
2127
|
+
);
|
|
2128
|
+
const anyMatch = availableModels.find(m => m.id === modelId);
|
|
2129
|
+
model = exactProviderMatch ?? anyMatch;
|
|
2130
|
+
|
|
2131
|
+
// Warn if the ID is ambiguous across providers
|
|
2132
|
+
if (anyMatch && !exactProviderMatch) {
|
|
2133
|
+
const providers = availableModels
|
|
2134
|
+
.filter(m => m.id === modelId)
|
|
2135
|
+
.map(m => m.provider);
|
|
2136
|
+
if (providers.length > 1) {
|
|
2137
|
+
ctx.ui.notify(
|
|
2138
|
+
`Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
|
|
2139
|
+
`Resolved to ${anyMatch.provider}. Use "provider/model" format for explicit targeting.`,
|
|
2140
|
+
"warning",
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
if (!model) {
|
|
2146
|
+
ctx.ui.notify(`Model ${modelId} not found in available models, trying fallback.`, "warning");
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const ok = await pi.setModel(model, { persist: false });
|
|
2151
|
+
if (ok) {
|
|
2152
|
+
const fallbackNote = modelId === modelConfig.primary
|
|
2153
|
+
? ""
|
|
2154
|
+
: ` (fallback from ${modelConfig.primary})`;
|
|
2155
|
+
const phase = unitPhaseLabel(unitType);
|
|
2156
|
+
ctx.ui.notify(`Model [${phase}]: ${model.provider}/${model.id}${fallbackNote}`, "info");
|
|
2157
|
+
modelSet = true;
|
|
2158
|
+
break;
|
|
2159
|
+
} else {
|
|
2160
|
+
const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
|
|
2161
|
+
if (nextModel) {
|
|
2162
|
+
ctx.ui.notify(
|
|
2163
|
+
`Failed to set model ${modelId}, trying fallback ${nextModel}...`,
|
|
2164
|
+
"warning",
|
|
2165
|
+
);
|
|
2166
|
+
} else {
|
|
2167
|
+
ctx.ui.notify(
|
|
2168
|
+
`Failed to set model ${modelId} and all fallbacks exhausted. Using default model.`,
|
|
2169
|
+
"warning",
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (!modelSet) {
|
|
2176
|
+
ctx.ui.notify(
|
|
2177
|
+
`Could not set any preferred model for ${unitType}. Continuing with default.`,
|
|
2178
|
+
"warning",
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// Start progress-aware supervision: a soft warning, an idle watchdog, and
|
|
2184
|
+
// a larger hard ceiling. Productive long-running tasks may continue past the
|
|
2185
|
+
// soft timeout; only idle/stalled tasks pause early.
|
|
2186
|
+
clearUnitTimeout();
|
|
2187
|
+
const supervisor = resolveAutoSupervisorConfig();
|
|
2188
|
+
const softTimeoutMs = (supervisor.soft_timeout_minutes ?? 0) * 60 * 1000;
|
|
2189
|
+
const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000;
|
|
2190
|
+
const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000;
|
|
2191
|
+
|
|
2192
|
+
wrapupWarningHandle = setTimeout(() => {
|
|
2193
|
+
wrapupWarningHandle = null;
|
|
2194
|
+
if (!active || !currentUnit) return;
|
|
2195
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2196
|
+
phase: "wrapup-warning-sent",
|
|
2197
|
+
wrapupWarningSent: true,
|
|
2198
|
+
});
|
|
2199
|
+
pi.sendMessage(
|
|
2200
|
+
{
|
|
2201
|
+
customType: "gsd-auto-wrapup",
|
|
2202
|
+
display: verbose,
|
|
2203
|
+
content: [
|
|
2204
|
+
"**TIME BUDGET WARNING — keep going only if progress is real.**",
|
|
2205
|
+
"This unit crossed the soft time budget.",
|
|
2206
|
+
"If you are making progress, continue. If not, switch to wrap-up mode now:",
|
|
2207
|
+
"1. rerun the minimal required verification",
|
|
2208
|
+
"2. write or update the required durable artifacts",
|
|
2209
|
+
"3. mark task or slice state on disk correctly",
|
|
2210
|
+
"4. leave precise resume notes if anything remains unfinished",
|
|
2211
|
+
].join("\n"),
|
|
2212
|
+
},
|
|
2213
|
+
{ triggerTurn: true },
|
|
2214
|
+
);
|
|
2215
|
+
}, softTimeoutMs);
|
|
2216
|
+
|
|
2217
|
+
idleWatchdogHandle = setInterval(async () => {
|
|
2218
|
+
if (!active || !currentUnit) return;
|
|
2219
|
+
const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
|
|
2220
|
+
if (!runtime) return;
|
|
2221
|
+
if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
|
|
2222
|
+
|
|
2223
|
+
// Before triggering recovery, check if the agent is actually producing
|
|
2224
|
+
// work on disk. `git status --porcelain` is cheap and catches any
|
|
2225
|
+
// staged/unstaged/untracked changes the agent made since lastProgressAt.
|
|
2226
|
+
if (detectWorkingTreeActivity(basePath)) {
|
|
2227
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2228
|
+
lastProgressAt: Date.now(),
|
|
2229
|
+
lastProgressKind: "filesystem-activity",
|
|
2230
|
+
});
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
if (currentUnit) {
|
|
2235
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
2236
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
2237
|
+
}
|
|
2238
|
+
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
2239
|
+
|
|
2240
|
+
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle");
|
|
2241
|
+
if (recovery === "recovered") return;
|
|
2242
|
+
|
|
2243
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2244
|
+
phase: "paused",
|
|
2245
|
+
});
|
|
2246
|
+
ctx.ui.notify(
|
|
2247
|
+
`Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`,
|
|
2248
|
+
"warning",
|
|
2249
|
+
);
|
|
2250
|
+
await pauseAuto(ctx, pi);
|
|
2251
|
+
}, 15000);
|
|
2252
|
+
|
|
2253
|
+
unitTimeoutHandle = setTimeout(async () => {
|
|
2254
|
+
unitTimeoutHandle = null;
|
|
2255
|
+
if (!active) return;
|
|
2256
|
+
if (currentUnit) {
|
|
2257
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2258
|
+
phase: "timeout",
|
|
2259
|
+
timeoutAt: Date.now(),
|
|
2260
|
+
});
|
|
2261
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
2262
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
2263
|
+
}
|
|
2264
|
+
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
2265
|
+
|
|
2266
|
+
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard");
|
|
2267
|
+
if (recovery === "recovered") return;
|
|
2268
|
+
|
|
2269
|
+
ctx.ui.notify(
|
|
2270
|
+
`Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`,
|
|
2271
|
+
"warning",
|
|
2272
|
+
);
|
|
2273
|
+
await pauseAuto(ctx, pi);
|
|
2274
|
+
}, hardTimeoutMs);
|
|
2275
|
+
|
|
2276
|
+
// Inject prompt — verify auto-mode still active (guards against race with timeout/pause)
|
|
2277
|
+
if (!active) return;
|
|
2278
|
+
pi.sendMessage(
|
|
2279
|
+
{ customType: "gsd-auto", content: finalPrompt, display: verbose },
|
|
2280
|
+
{ triggerTurn: true },
|
|
2281
|
+
);
|
|
2282
|
+
|
|
2283
|
+
// For non-artifact-driven UAT types, pause auto-mode after sending the prompt.
|
|
2284
|
+
// The agent will write the UAT result file surfacing it for human review,
|
|
2285
|
+
// then on resume the result file exists and run-uat is skipped automatically.
|
|
2286
|
+
if (pauseAfterUatDispatch) {
|
|
2287
|
+
ctx.ui.notify(
|
|
2288
|
+
"UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
|
|
2289
|
+
"info",
|
|
2290
|
+
);
|
|
2291
|
+
await pauseAuto(ctx, pi);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ─── Skill Discovery ──────────────────────────────────────────────────────────
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* Build the skill discovery template variables for research prompts.
|
|
2299
|
+
* Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution.
|
|
2300
|
+
*/
|
|
2301
|
+
function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } {
|
|
2302
|
+
const mode = resolveSkillDiscoveryMode();
|
|
2303
|
+
|
|
2304
|
+
if (mode === "off") {
|
|
2305
|
+
return {
|
|
2306
|
+
skillDiscoveryMode: "off",
|
|
2307
|
+
skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.",
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const autoInstall = mode === "auto";
|
|
2312
|
+
const instructions = `
|
|
2313
|
+
Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI).
|
|
2314
|
+
For each, check if a professional agent skill already exists:
|
|
2315
|
+
- First check \`<available_skills>\` in your system prompt — a skill may already be installed.
|
|
2316
|
+
- For technologies without an installed skill, run: \`npx skills find "<technology>"\`
|
|
2317
|
+
- Only consider skills that are **directly relevant** to core technologies — not tangentially related.
|
|
2318
|
+
- Evaluate results by install count and relevance to the actual work.${autoInstall
|
|
2319
|
+
? `
|
|
2320
|
+
- Install relevant skills: \`npx skills add <owner/repo@skill> -g -y\`
|
|
2321
|
+
- Record installed skills in the "Skills Discovered" section of your research output.
|
|
2322
|
+
- Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.`
|
|
2323
|
+
: `
|
|
2324
|
+
- Note promising skills in your research output with their install commands, but do NOT install them.
|
|
2325
|
+
- The user will decide which to install.`
|
|
2326
|
+
}`;
|
|
2327
|
+
|
|
2328
|
+
return {
|
|
2329
|
+
skillDiscoveryMode: mode,
|
|
2330
|
+
skillDiscoveryInstructions: instructions,
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// ─── Inline Helpers ───────────────────────────────────────────────────────────
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* Load a file and format it for inlining into a prompt.
|
|
2338
|
+
* Returns the content wrapped with a source path header, or a fallback
|
|
2339
|
+
* message if the file doesn't exist. This eliminates tool calls — the LLM
|
|
2340
|
+
* gets the content directly instead of "Read this file:".
|
|
2341
|
+
*/
|
|
2342
|
+
async function inlineFile(
|
|
2343
|
+
absPath: string | null, relPath: string, label: string,
|
|
2344
|
+
): Promise<string> {
|
|
2345
|
+
const content = absPath ? await loadFile(absPath) : null;
|
|
2346
|
+
if (!content) {
|
|
2347
|
+
return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`;
|
|
2348
|
+
}
|
|
2349
|
+
return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Load a file for inlining, returning null if it doesn't exist.
|
|
2354
|
+
* Use when the file is optional and should be omitted entirely if absent.
|
|
2355
|
+
*/
|
|
2356
|
+
async function inlineFileOptional(
|
|
2357
|
+
absPath: string | null, relPath: string, label: string,
|
|
2358
|
+
): Promise<string | null> {
|
|
2359
|
+
const content = absPath ? await loadFile(absPath) : null;
|
|
2360
|
+
if (!content) return null;
|
|
2361
|
+
return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Load and inline dependency slice summaries (full content, not just paths).
|
|
2366
|
+
*/
|
|
2367
|
+
async function inlineDependencySummaries(
|
|
2368
|
+
mid: string, sid: string, base: string,
|
|
2369
|
+
): Promise<string> {
|
|
2370
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2371
|
+
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
2372
|
+
if (!roadmapContent) return "- (no dependencies)";
|
|
2373
|
+
|
|
2374
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
2375
|
+
const sliceEntry = roadmap.slices.find(s => s.id === sid);
|
|
2376
|
+
if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)";
|
|
2377
|
+
|
|
2378
|
+
const sections: string[] = [];
|
|
2379
|
+
const seen = new Set<string>();
|
|
2380
|
+
for (const dep of sliceEntry.depends) {
|
|
2381
|
+
if (seen.has(dep)) continue;
|
|
2382
|
+
seen.add(dep);
|
|
2383
|
+
const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY");
|
|
2384
|
+
const summaryContent = summaryFile ? await loadFile(summaryFile) : null;
|
|
2385
|
+
const relPath = relSliceFile(base, mid, dep, "SUMMARY");
|
|
2386
|
+
if (summaryContent) {
|
|
2387
|
+
sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`);
|
|
2388
|
+
} else {
|
|
2389
|
+
sections.push(`- \`${relPath}\` _(not found)_`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return sections.join("\n\n");
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/**
|
|
2396
|
+
* Load a well-known .gsd/ root file for optional inlining.
|
|
2397
|
+
* Handles the existsSync check internally.
|
|
2398
|
+
*/
|
|
2399
|
+
async function inlineGsdRootFile(
|
|
2400
|
+
base: string, filename: string, label: string,
|
|
2401
|
+
): Promise<string | null> {
|
|
2402
|
+
const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS";
|
|
2403
|
+
const absPath = resolveGsdRootFile(base, key);
|
|
2404
|
+
if (!existsSync(absPath)) return null;
|
|
2405
|
+
return inlineFileOptional(absPath, relGsdRootFile(key), label);
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// ─── Prompt Builders ──────────────────────────────────────────────────────────
|
|
2409
|
+
|
|
2410
|
+
async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
|
|
2411
|
+
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
|
2412
|
+
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
|
2413
|
+
|
|
2414
|
+
const inlined: string[] = [];
|
|
2415
|
+
inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
|
|
2416
|
+
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
|
|
2417
|
+
if (projectInline) inlined.push(projectInline);
|
|
2418
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2419
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2420
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2421
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2422
|
+
inlined.push(inlineTemplate("research", "Research"));
|
|
2423
|
+
|
|
2424
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2425
|
+
|
|
2426
|
+
const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
|
|
2427
|
+
return loadPrompt("research-milestone", {
|
|
2428
|
+
milestoneId: mid, milestoneTitle: midTitle,
|
|
2429
|
+
milestonePath: relMilestonePath(base, mid),
|
|
2430
|
+
contextPath: contextRel,
|
|
2431
|
+
outputPath: outputRelPath,
|
|
2432
|
+
inlinedContext,
|
|
2433
|
+
...buildSkillDiscoveryVars(),
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
|
|
2438
|
+
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
|
2439
|
+
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
|
2440
|
+
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
|
|
2441
|
+
const researchRel = relMilestoneFile(base, mid, "RESEARCH");
|
|
2442
|
+
|
|
2443
|
+
const inlined: string[] = [];
|
|
2444
|
+
inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
|
|
2445
|
+
const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research");
|
|
2446
|
+
if (researchInline) inlined.push(researchInline);
|
|
2447
|
+
const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
|
|
2448
|
+
if (priorSummaryInline) inlined.push(priorSummaryInline);
|
|
2449
|
+
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
|
|
2450
|
+
if (projectInline) inlined.push(projectInline);
|
|
2451
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2452
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2453
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2454
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2455
|
+
inlined.push(inlineTemplate("roadmap", "Roadmap"));
|
|
2456
|
+
inlined.push(inlineTemplate("decisions", "Decisions"));
|
|
2457
|
+
inlined.push(inlineTemplate("plan", "Slice Plan"));
|
|
2458
|
+
inlined.push(inlineTemplate("task-plan", "Task Plan"));
|
|
2459
|
+
inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest"));
|
|
2460
|
+
|
|
2461
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2462
|
+
|
|
2463
|
+
const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
|
|
2464
|
+
const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
|
|
2465
|
+
return loadPrompt("plan-milestone", {
|
|
2466
|
+
milestoneId: mid, milestoneTitle: midTitle,
|
|
2467
|
+
milestonePath: relMilestonePath(base, mid),
|
|
2468
|
+
contextPath: contextRel,
|
|
2469
|
+
researchPath: researchRel,
|
|
2470
|
+
outputPath: outputRelPath,
|
|
2471
|
+
secretsOutputPath,
|
|
2472
|
+
inlinedContext,
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
async function buildResearchSlicePrompt(
|
|
2477
|
+
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
|
|
2478
|
+
): Promise<string> {
|
|
2479
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2480
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2481
|
+
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
|
2482
|
+
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
|
2483
|
+
const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH");
|
|
2484
|
+
const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH");
|
|
2485
|
+
|
|
2486
|
+
const inlined: string[] = [];
|
|
2487
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
|
2488
|
+
const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
|
|
2489
|
+
if (contextInline) inlined.push(contextInline);
|
|
2490
|
+
const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research");
|
|
2491
|
+
if (researchInline) inlined.push(researchInline);
|
|
2492
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2493
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2494
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2495
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2496
|
+
inlined.push(inlineTemplate("research", "Research"));
|
|
2497
|
+
|
|
2498
|
+
const depContent = await inlineDependencySummaries(mid, sid, base);
|
|
2499
|
+
|
|
2500
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2501
|
+
|
|
2502
|
+
const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
|
|
2503
|
+
return loadPrompt("research-slice", {
|
|
2504
|
+
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
|
|
2505
|
+
slicePath: relSlicePath(base, mid, sid),
|
|
2506
|
+
roadmapPath: roadmapRel,
|
|
2507
|
+
contextPath: contextRel,
|
|
2508
|
+
milestoneResearchPath: milestoneResearchRel,
|
|
2509
|
+
outputPath: outputRelPath,
|
|
2510
|
+
inlinedContext,
|
|
2511
|
+
dependencySummaries: depContent,
|
|
2512
|
+
...buildSkillDiscoveryVars(),
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
async function buildPlanSlicePrompt(
|
|
2517
|
+
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
|
|
2518
|
+
): Promise<string> {
|
|
2519
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2520
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2521
|
+
const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
|
|
2522
|
+
const researchRel = relSliceFile(base, mid, sid, "RESEARCH");
|
|
2523
|
+
|
|
2524
|
+
const inlined: string[] = [];
|
|
2525
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
|
2526
|
+
const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research");
|
|
2527
|
+
if (researchInline) inlined.push(researchInline);
|
|
2528
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2529
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2530
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2531
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2532
|
+
inlined.push(inlineTemplate("plan", "Slice Plan"));
|
|
2533
|
+
inlined.push(inlineTemplate("task-plan", "Task Plan"));
|
|
2534
|
+
|
|
2535
|
+
const depContent = await inlineDependencySummaries(mid, sid, base);
|
|
2536
|
+
|
|
2537
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2538
|
+
|
|
2539
|
+
const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
|
|
2540
|
+
return loadPrompt("plan-slice", {
|
|
2541
|
+
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
|
|
2542
|
+
slicePath: relSlicePath(base, mid, sid),
|
|
2543
|
+
roadmapPath: roadmapRel,
|
|
2544
|
+
researchPath: researchRel,
|
|
2545
|
+
outputPath: outputRelPath,
|
|
2546
|
+
inlinedContext,
|
|
2547
|
+
dependencySummaries: depContent,
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
async function buildExecuteTaskPrompt(
|
|
2552
|
+
mid: string, sid: string, sTitle: string,
|
|
2553
|
+
tid: string, tTitle: string, base: string,
|
|
2554
|
+
): Promise<string> {
|
|
2555
|
+
|
|
2556
|
+
const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
|
|
2557
|
+
const priorLines = priorSummaries.length > 0
|
|
2558
|
+
? priorSummaries.map(p => `- \`${p}\``).join("\n")
|
|
2559
|
+
: "- (no prior tasks)";
|
|
2560
|
+
|
|
2561
|
+
const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
|
|
2562
|
+
const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null;
|
|
2563
|
+
const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN");
|
|
2564
|
+
const taskPlanInline = taskPlanContent
|
|
2565
|
+
? [
|
|
2566
|
+
"## Inlined Task Plan (authoritative local execution contract)",
|
|
2567
|
+
`Source: \`${taskPlanRelPath}\``,
|
|
2568
|
+
"",
|
|
2569
|
+
taskPlanContent.trim(),
|
|
2570
|
+
].join("\n")
|
|
2571
|
+
: [
|
|
2572
|
+
"## Inlined Task Plan (authoritative local execution contract)",
|
|
2573
|
+
`Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`,
|
|
2574
|
+
].join("\n");
|
|
2575
|
+
|
|
2576
|
+
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
|
|
2577
|
+
const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
|
|
2578
|
+
const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN"));
|
|
2579
|
+
|
|
2580
|
+
// Check for continue file (new naming or legacy)
|
|
2581
|
+
const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE");
|
|
2582
|
+
const legacyContinueDir = resolveSlicePath(base, mid, sid);
|
|
2583
|
+
const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null;
|
|
2584
|
+
const continueContent = continueFile ? await loadFile(continueFile) : null;
|
|
2585
|
+
const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null;
|
|
2586
|
+
const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE");
|
|
2587
|
+
const resumeSection = buildResumeSection(
|
|
2588
|
+
continueContent,
|
|
2589
|
+
legacyContinueContent,
|
|
2590
|
+
continueRelPath,
|
|
2591
|
+
legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null,
|
|
2592
|
+
);
|
|
2593
|
+
|
|
2594
|
+
const carryForwardSection = await buildCarryForwardSection(priorSummaries, base);
|
|
2595
|
+
const inlinedTemplates = [
|
|
2596
|
+
inlineTemplate("task-summary", "Task Summary"),
|
|
2597
|
+
inlineTemplate("decisions", "Decisions"),
|
|
2598
|
+
].join("\n\n---\n\n");
|
|
2599
|
+
|
|
2600
|
+
const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`;
|
|
2601
|
+
|
|
2602
|
+
return loadPrompt("execute-task", {
|
|
2603
|
+
milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle,
|
|
2604
|
+
planPath: relSliceFile(base, mid, sid, "PLAN"),
|
|
2605
|
+
slicePath: relSlicePath(base, mid, sid),
|
|
2606
|
+
taskPlanPath: taskPlanRelPath,
|
|
2607
|
+
taskPlanInline,
|
|
2608
|
+
slicePlanExcerpt,
|
|
2609
|
+
carryForwardSection,
|
|
2610
|
+
resumeSection,
|
|
2611
|
+
priorTaskLines: priorLines,
|
|
2612
|
+
taskSummaryPath,
|
|
2613
|
+
inlinedTemplates,
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
async function buildCompleteSlicePrompt(
|
|
2618
|
+
mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
|
|
2619
|
+
): Promise<string> {
|
|
2620
|
+
|
|
2621
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2622
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2623
|
+
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
|
|
2624
|
+
const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
|
|
2625
|
+
|
|
2626
|
+
const inlined: string[] = [];
|
|
2627
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
|
2628
|
+
inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan"));
|
|
2629
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2630
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2631
|
+
|
|
2632
|
+
// Inline all task summaries for this slice
|
|
2633
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
2634
|
+
if (tDir) {
|
|
2635
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
|
|
2636
|
+
for (const file of summaryFiles) {
|
|
2637
|
+
const absPath = join(tDir, file);
|
|
2638
|
+
const content = await loadFile(absPath);
|
|
2639
|
+
const sRel = relSlicePath(base, mid, sid);
|
|
2640
|
+
const relPath = `${sRel}/tasks/${file}`;
|
|
2641
|
+
if (content) {
|
|
2642
|
+
inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
inlined.push(inlineTemplate("slice-summary", "Slice Summary"));
|
|
2647
|
+
inlined.push(inlineTemplate("uat", "UAT"));
|
|
2648
|
+
|
|
2649
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2650
|
+
|
|
2651
|
+
const sliceRel = relSlicePath(base, mid, sid);
|
|
2652
|
+
const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`;
|
|
2653
|
+
const sliceUatPath = `${sliceRel}/${sid}-UAT.md`;
|
|
2654
|
+
|
|
2655
|
+
return loadPrompt("complete-slice", {
|
|
2656
|
+
milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
|
|
2657
|
+
slicePath: sliceRel,
|
|
2658
|
+
roadmapPath: roadmapRel,
|
|
2659
|
+
inlinedContext,
|
|
2660
|
+
sliceSummaryPath,
|
|
2661
|
+
sliceUatPath,
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
async function buildCompleteMilestonePrompt(
|
|
2666
|
+
mid: string, midTitle: string, base: string,
|
|
2667
|
+
): Promise<string> {
|
|
2668
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2669
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2670
|
+
|
|
2671
|
+
const inlined: string[] = [];
|
|
2672
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
|
2673
|
+
|
|
2674
|
+
// Inline all slice summaries (deduplicated by slice ID)
|
|
2675
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
2676
|
+
if (roadmapContent) {
|
|
2677
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
2678
|
+
const seenSlices = new Set<string>();
|
|
2679
|
+
for (const slice of roadmap.slices) {
|
|
2680
|
+
if (seenSlices.has(slice.id)) continue;
|
|
2681
|
+
seenSlices.add(slice.id);
|
|
2682
|
+
const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY");
|
|
2683
|
+
const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY");
|
|
2684
|
+
inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`));
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// Inline root GSD files
|
|
2689
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2690
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2691
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2692
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2693
|
+
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
|
|
2694
|
+
if (projectInline) inlined.push(projectInline);
|
|
2695
|
+
// Inline milestone context file (milestone-level, not GSD root)
|
|
2696
|
+
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
|
2697
|
+
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
|
2698
|
+
const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
|
|
2699
|
+
if (contextInline) inlined.push(contextInline);
|
|
2700
|
+
inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
|
|
2701
|
+
|
|
2702
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2703
|
+
|
|
2704
|
+
const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`;
|
|
2705
|
+
|
|
2706
|
+
return loadPrompt("complete-milestone", {
|
|
2707
|
+
milestoneId: mid,
|
|
2708
|
+
milestoneTitle: midTitle,
|
|
2709
|
+
roadmapPath: roadmapRel,
|
|
2710
|
+
inlinedContext,
|
|
2711
|
+
milestoneSummaryPath,
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// ─── Replan Slice Prompt ───────────────────────────────────────────────────────
|
|
2716
|
+
|
|
2717
|
+
async function buildReplanSlicePrompt(
|
|
2718
|
+
mid: string, midTitle: string, sid: string, sTitle: string, base: string,
|
|
2719
|
+
): Promise<string> {
|
|
2720
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2721
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2722
|
+
const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
|
|
2723
|
+
const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
|
|
2724
|
+
|
|
2725
|
+
const inlined: string[] = [];
|
|
2726
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
|
2727
|
+
inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan"));
|
|
2728
|
+
|
|
2729
|
+
// Find the blocker task summary — the completed task with blocker_discovered: true
|
|
2730
|
+
let blockerTaskId = "";
|
|
2731
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
2732
|
+
if (tDir) {
|
|
2733
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
|
|
2734
|
+
for (const file of summaryFiles) {
|
|
2735
|
+
const absPath = join(tDir, file);
|
|
2736
|
+
const content = await loadFile(absPath);
|
|
2737
|
+
if (!content) continue;
|
|
2738
|
+
const summary = parseSummary(content);
|
|
2739
|
+
const sRel = relSlicePath(base, mid, sid);
|
|
2740
|
+
const relPath = `${sRel}/tasks/${file}`;
|
|
2741
|
+
if (summary.frontmatter.blocker_discovered) {
|
|
2742
|
+
blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, "");
|
|
2743
|
+
inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// Inline decisions
|
|
2749
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2750
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2751
|
+
|
|
2752
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2753
|
+
|
|
2754
|
+
const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`;
|
|
2755
|
+
|
|
2756
|
+
return loadPrompt("replan-slice", {
|
|
2757
|
+
milestoneId: mid,
|
|
2758
|
+
sliceId: sid,
|
|
2759
|
+
sliceTitle: sTitle,
|
|
2760
|
+
slicePath: relSlicePath(base, mid, sid),
|
|
2761
|
+
planPath: slicePlanRel,
|
|
2762
|
+
blockerTaskId,
|
|
2763
|
+
inlinedContext,
|
|
2764
|
+
replanPath,
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// ─── Adaptive Replanning ──────────────────────────────────────────────────────
|
|
2769
|
+
|
|
2770
|
+
/**
|
|
2771
|
+
* Check if the most recently completed slice needs reassessment.
|
|
2772
|
+
* Returns { sliceId } if reassessment is needed, null otherwise.
|
|
2773
|
+
*
|
|
2774
|
+
* Skips reassessment when:
|
|
2775
|
+
* - No roadmap exists yet
|
|
2776
|
+
* - No slices are completed
|
|
2777
|
+
* - The last completed slice already has an assessment file
|
|
2778
|
+
* - All slices are complete (milestone done — no point reassessing)
|
|
2779
|
+
*/
|
|
2780
|
+
async function checkNeedsReassessment(
|
|
2781
|
+
base: string, mid: string, state: GSDState,
|
|
2782
|
+
): Promise<{ sliceId: string } | null> {
|
|
2783
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2784
|
+
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
2785
|
+
if (!roadmapContent) return null;
|
|
2786
|
+
|
|
2787
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
2788
|
+
const completedSlices = roadmap.slices.filter(s => s.done);
|
|
2789
|
+
const incompleteSlices = roadmap.slices.filter(s => !s.done);
|
|
2790
|
+
|
|
2791
|
+
// No completed slices or all slices done — skip
|
|
2792
|
+
if (completedSlices.length === 0 || incompleteSlices.length === 0) return null;
|
|
2793
|
+
|
|
2794
|
+
// Check the last completed slice
|
|
2795
|
+
const lastCompleted = completedSlices[completedSlices.length - 1];
|
|
2796
|
+
const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT");
|
|
2797
|
+
const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile));
|
|
2798
|
+
|
|
2799
|
+
if (hasAssessment) return null;
|
|
2800
|
+
|
|
2801
|
+
// Also need a summary to reassess against
|
|
2802
|
+
const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY");
|
|
2803
|
+
const hasSummary = !!(summaryFile && await loadFile(summaryFile));
|
|
2804
|
+
|
|
2805
|
+
if (!hasSummary) return null;
|
|
2806
|
+
|
|
2807
|
+
return { sliceId: lastCompleted.id };
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/**
|
|
2811
|
+
* Check if the most recently completed slice needs a UAT run.
|
|
2812
|
+
* Returns { sliceId, uatType } if UAT should be dispatched, null otherwise.
|
|
2813
|
+
*
|
|
2814
|
+
* Skips when:
|
|
2815
|
+
* - No roadmap or no completed slices
|
|
2816
|
+
* - All slices are done (milestone complete path — reassessment handles it)
|
|
2817
|
+
* - uat_dispatch preference is not enabled
|
|
2818
|
+
* - No UAT file exists for the slice
|
|
2819
|
+
* - UAT result file already exists (idempotent — already ran)
|
|
2820
|
+
*/
|
|
2821
|
+
async function checkNeedsRunUat(
|
|
2822
|
+
base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined,
|
|
2823
|
+
): Promise<{ sliceId: string; uatType: UatType } | null> {
|
|
2824
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2825
|
+
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
2826
|
+
if (!roadmapContent) return null;
|
|
2827
|
+
|
|
2828
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
2829
|
+
const completedSlices = roadmap.slices.filter(s => s.done);
|
|
2830
|
+
const incompleteSlices = roadmap.slices.filter(s => !s.done);
|
|
2831
|
+
|
|
2832
|
+
// No completed slices — nothing to UAT yet
|
|
2833
|
+
if (completedSlices.length === 0) return null;
|
|
2834
|
+
|
|
2835
|
+
// All slices done — milestone complete path, skip (reassessment handles)
|
|
2836
|
+
if (incompleteSlices.length === 0) return null;
|
|
2837
|
+
|
|
2838
|
+
// uat_dispatch must be opted in
|
|
2839
|
+
if (!prefs?.uat_dispatch) return null;
|
|
2840
|
+
|
|
2841
|
+
// Take the last completed slice
|
|
2842
|
+
const lastCompleted = completedSlices[completedSlices.length - 1];
|
|
2843
|
+
const sid = lastCompleted.id;
|
|
2844
|
+
|
|
2845
|
+
// UAT file must exist
|
|
2846
|
+
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
|
|
2847
|
+
if (!uatFile) return null;
|
|
2848
|
+
const uatContent = await loadFile(uatFile);
|
|
2849
|
+
if (!uatContent) return null;
|
|
2850
|
+
|
|
2851
|
+
// If UAT result already exists, skip (idempotent)
|
|
2852
|
+
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
|
|
2853
|
+
if (uatResultFile) {
|
|
2854
|
+
const hasResult = !!(await loadFile(uatResultFile));
|
|
2855
|
+
if (hasResult) return null;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// Classify UAT type; unknown type → treat as human-experience (human review)
|
|
2859
|
+
const uatType = extractUatType(uatContent) ?? "human-experience";
|
|
2860
|
+
|
|
2861
|
+
return { sliceId: sid, uatType };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
async function buildRunUatPrompt(
|
|
2865
|
+
mid: string, sliceId: string, uatPath: string, uatContent: string, base: string,
|
|
2866
|
+
): Promise<string> {
|
|
2867
|
+
const inlined: string[] = [];
|
|
2868
|
+
inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`));
|
|
2869
|
+
|
|
2870
|
+
const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY");
|
|
2871
|
+
const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY");
|
|
2872
|
+
if (summaryPath) {
|
|
2873
|
+
const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`);
|
|
2874
|
+
if (summaryInline) inlined.push(summaryInline);
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
|
|
2878
|
+
if (projectInline) inlined.push(projectInline);
|
|
2879
|
+
|
|
2880
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2881
|
+
|
|
2882
|
+
const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT");
|
|
2883
|
+
const uatType = extractUatType(uatContent) ?? "human-experience";
|
|
2884
|
+
|
|
2885
|
+
return loadPrompt("run-uat", {
|
|
2886
|
+
milestoneId: mid,
|
|
2887
|
+
sliceId,
|
|
2888
|
+
uatPath,
|
|
2889
|
+
uatResultPath,
|
|
2890
|
+
uatType,
|
|
2891
|
+
inlinedContext,
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
async function buildReassessRoadmapPrompt(
|
|
2896
|
+
mid: string, midTitle: string, completedSliceId: string, base: string,
|
|
2897
|
+
): Promise<string> {
|
|
2898
|
+
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
2899
|
+
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
2900
|
+
const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY");
|
|
2901
|
+
const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY");
|
|
2902
|
+
|
|
2903
|
+
const inlined: string[] = [];
|
|
2904
|
+
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap"));
|
|
2905
|
+
inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`));
|
|
2906
|
+
const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
|
|
2907
|
+
if (projectInline) inlined.push(projectInline);
|
|
2908
|
+
const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
|
|
2909
|
+
if (requirementsInline) inlined.push(requirementsInline);
|
|
2910
|
+
const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
|
|
2911
|
+
if (decisionsInline) inlined.push(decisionsInline);
|
|
2912
|
+
|
|
2913
|
+
const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
|
|
2914
|
+
|
|
2915
|
+
const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
|
|
2916
|
+
|
|
2917
|
+
return loadPrompt("reassess-roadmap", {
|
|
2918
|
+
milestoneId: mid,
|
|
2919
|
+
milestoneTitle: midTitle,
|
|
2920
|
+
completedSliceId,
|
|
2921
|
+
roadmapPath: roadmapRel,
|
|
2922
|
+
completedSliceSummaryPath: summaryRel,
|
|
2923
|
+
assessmentPath,
|
|
2924
|
+
inlinedContext,
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* Build a prompt for the fix-merge LLM session that resolves merge conflicts.
|
|
2930
|
+
*/
|
|
2931
|
+
function buildFixMergePrompt(err: MergeConflictError): string {
|
|
2932
|
+
const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
|
|
2933
|
+
const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
|
|
2934
|
+
|
|
2935
|
+
return [
|
|
2936
|
+
`# Fix Merge Conflicts`,
|
|
2937
|
+
``,
|
|
2938
|
+
`A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
|
|
2939
|
+
``,
|
|
2940
|
+
fileList,
|
|
2941
|
+
``,
|
|
2942
|
+
`## Instructions`,
|
|
2943
|
+
``,
|
|
2944
|
+
`1. Read each conflicted file listed above`,
|
|
2945
|
+
`2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
|
|
2946
|
+
`3. Stage the resolved files with \`git add <file>\``,
|
|
2947
|
+
`4. Commit the resolution:`,
|
|
2948
|
+
err.strategy === "squash"
|
|
2949
|
+
? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
|
|
2950
|
+
: ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
|
|
2951
|
+
``,
|
|
2952
|
+
`## Rules`,
|
|
2953
|
+
``,
|
|
2954
|
+
`- Do NOT run \`git merge --abort\` or \`git reset\``,
|
|
2955
|
+
`- Do NOT modify any files other than the conflicted ones listed above`,
|
|
2956
|
+
`- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
|
|
2957
|
+
``,
|
|
2958
|
+
`## Verification`,
|
|
2959
|
+
``,
|
|
2960
|
+
`After committing, verify:`,
|
|
2961
|
+
`1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
|
|
2962
|
+
`2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
|
|
2963
|
+
`3. \`git status\` shows a clean working tree`,
|
|
2964
|
+
].join("\n");
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
|
|
2968
|
+
if (!content) {
|
|
2969
|
+
return [
|
|
2970
|
+
"## Slice Plan Excerpt",
|
|
2971
|
+
`Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`,
|
|
2972
|
+
].join("\n");
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
const lines = content.split("\n");
|
|
2976
|
+
const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim();
|
|
2977
|
+
const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim();
|
|
2978
|
+
|
|
2979
|
+
const verification = extractMarkdownSection(content, "Verification");
|
|
2980
|
+
const observability = extractMarkdownSection(content, "Observability / Diagnostics");
|
|
2981
|
+
|
|
2982
|
+
const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``];
|
|
2983
|
+
if (goalLine) parts.push(goalLine);
|
|
2984
|
+
if (demoLine) parts.push(demoLine);
|
|
2985
|
+
if (verification) {
|
|
2986
|
+
parts.push("", "### Slice Verification", verification.trim());
|
|
2987
|
+
}
|
|
2988
|
+
if (observability) {
|
|
2989
|
+
parts.push("", "### Slice Observability / Diagnostics", observability.trim());
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
return parts.join("\n");
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function extractMarkdownSection(content: string, heading: string): string | null {
|
|
2996
|
+
const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content);
|
|
2997
|
+
if (!match) return null;
|
|
2998
|
+
|
|
2999
|
+
const start = match.index + match[0].length;
|
|
3000
|
+
const rest = content.slice(start);
|
|
3001
|
+
const nextHeading = rest.match(/^##\s+/m);
|
|
3002
|
+
const end = nextHeading?.index ?? rest.length;
|
|
3003
|
+
return rest.slice(0, end).trim();
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function escapeRegExp(value: string): string {
|
|
3007
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function buildResumeSection(
|
|
3011
|
+
continueContent: string | null,
|
|
3012
|
+
legacyContinueContent: string | null,
|
|
3013
|
+
continueRelPath: string,
|
|
3014
|
+
legacyContinueRelPath: string | null,
|
|
3015
|
+
): string {
|
|
3016
|
+
const resolvedContent = continueContent ?? legacyContinueContent;
|
|
3017
|
+
const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath;
|
|
3018
|
+
|
|
3019
|
+
if (!resolvedContent || !resolvedRelPath) {
|
|
3020
|
+
return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n");
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
const cont = parseContinue(resolvedContent);
|
|
3024
|
+
const lines = [
|
|
3025
|
+
"## Resume State",
|
|
3026
|
+
`Source: \`${resolvedRelPath}\``,
|
|
3027
|
+
`- Status: ${cont.frontmatter.status || "in_progress"}`,
|
|
3028
|
+
];
|
|
3029
|
+
|
|
3030
|
+
if (cont.frontmatter.step && cont.frontmatter.totalSteps) {
|
|
3031
|
+
lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`);
|
|
3032
|
+
}
|
|
3033
|
+
if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`);
|
|
3034
|
+
if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`);
|
|
3035
|
+
if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`);
|
|
3036
|
+
if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`);
|
|
3037
|
+
|
|
3038
|
+
return lines.join("\n");
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise<string> {
|
|
3042
|
+
if (priorSummaryPaths.length === 0) {
|
|
3043
|
+
return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n");
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
const items = await Promise.all(priorSummaryPaths.map(async (relPath) => {
|
|
3047
|
+
const absPath = join(base, relPath);
|
|
3048
|
+
const content = await loadFile(absPath);
|
|
3049
|
+
if (!content) return `- \`${relPath}\``;
|
|
3050
|
+
|
|
3051
|
+
const summary = parseSummary(content);
|
|
3052
|
+
const provided = summary.frontmatter.provides.slice(0, 2).join("; ");
|
|
3053
|
+
const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; ");
|
|
3054
|
+
const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; ");
|
|
3055
|
+
const diagnostics = extractMarkdownSection(content, "Diagnostics");
|
|
3056
|
+
|
|
3057
|
+
const parts = [summary.title || relPath];
|
|
3058
|
+
if (summary.oneLiner) parts.push(summary.oneLiner);
|
|
3059
|
+
if (provided) parts.push(`provides: ${provided}`);
|
|
3060
|
+
if (decisions) parts.push(`decisions: ${decisions}`);
|
|
3061
|
+
if (patterns) parts.push(`patterns: ${patterns}`);
|
|
3062
|
+
if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`);
|
|
3063
|
+
|
|
3064
|
+
return `- \`${relPath}\` — ${parts.join(" | ")}`;
|
|
3065
|
+
}));
|
|
3066
|
+
|
|
3067
|
+
return ["## Carry-Forward Context", ...items].join("\n");
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
function oneLine(text: string): string {
|
|
3071
|
+
return text.replace(/\s+/g, " ").trim();
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
async function getPriorTaskSummaryPaths(
|
|
3075
|
+
mid: string, sid: string, currentTid: string, base: string,
|
|
3076
|
+
): Promise<string[]> {
|
|
3077
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
3078
|
+
if (!tDir) return [];
|
|
3079
|
+
|
|
3080
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
3081
|
+
const currentNum = parseInt(currentTid.replace(/^T/, ""), 10);
|
|
3082
|
+
const sRel = relSlicePath(base, mid, sid);
|
|
3083
|
+
|
|
3084
|
+
return summaryFiles
|
|
3085
|
+
.filter(f => {
|
|
3086
|
+
const num = parseInt(f.replace(/^T/, ""), 10);
|
|
3087
|
+
return num < currentNum;
|
|
3088
|
+
})
|
|
3089
|
+
.map(f => `${sRel}/tasks/${f}`);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// ─── Preconditions ────────────────────────────────────────────────────────────
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* Ensure directories, branches, and other prerequisites exist before
|
|
3096
|
+
* dispatching a unit. The LLM should never need to mkdir or git checkout.
|
|
3097
|
+
*/
|
|
3098
|
+
function ensurePreconditions(
|
|
3099
|
+
unitType: string, unitId: string, base: string, state: GSDState,
|
|
3100
|
+
): void {
|
|
3101
|
+
const parts = unitId.split("/");
|
|
3102
|
+
const mid = parts[0]!;
|
|
3103
|
+
|
|
3104
|
+
// Always ensure milestone dir exists
|
|
3105
|
+
const mDir = resolveMilestonePath(base, mid);
|
|
3106
|
+
if (!mDir) {
|
|
3107
|
+
const newDir = join(milestonesDir(base), mid);
|
|
3108
|
+
mkdirSync(join(newDir, "slices"), { recursive: true });
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// For slice-level units, ensure slice dir exists
|
|
3112
|
+
if (parts.length >= 2) {
|
|
3113
|
+
const sid = parts[1]!;
|
|
3114
|
+
|
|
3115
|
+
// Re-resolve milestone path after potential creation
|
|
3116
|
+
const mDirResolved = resolveMilestonePath(base, mid);
|
|
3117
|
+
if (mDirResolved) {
|
|
3118
|
+
const slicesDir = join(mDirResolved, "slices");
|
|
3119
|
+
const sDir = resolveDir(slicesDir, sid);
|
|
3120
|
+
if (!sDir) {
|
|
3121
|
+
// Create slice dir with bare ID
|
|
3122
|
+
const newSliceDir = join(slicesDir, sid);
|
|
3123
|
+
mkdirSync(join(newSliceDir, "tasks"), { recursive: true });
|
|
3124
|
+
} else {
|
|
3125
|
+
// Ensure tasks/ subdir exists
|
|
3126
|
+
const tasksDir = join(slicesDir, sDir, "tasks");
|
|
3127
|
+
if (!existsSync(tasksDir)) {
|
|
3128
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
|
|
3135
|
+
const sid = parts[1]!;
|
|
3136
|
+
ensureSliceBranch(base, mid, sid);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
// ─── Diagnostics ──────────────────────────────────────────────────────────────
|
|
3141
|
+
|
|
3142
|
+
async function collectObservabilityWarnings(
|
|
3143
|
+
ctx: ExtensionContext,
|
|
3144
|
+
unitType: string,
|
|
3145
|
+
unitId: string,
|
|
3146
|
+
): Promise<import("./observability-validator.ts").ValidationIssue[]> {
|
|
3147
|
+
// Hook units have custom artifacts — skip standard observability checks
|
|
3148
|
+
if (unitType.startsWith("hook/")) return [];
|
|
3149
|
+
|
|
3150
|
+
const parts = unitId.split("/");
|
|
3151
|
+
const mid = parts[0];
|
|
3152
|
+
const sid = parts[1];
|
|
3153
|
+
const tid = parts[2];
|
|
3154
|
+
|
|
3155
|
+
if (!mid || !sid) return [];
|
|
3156
|
+
|
|
3157
|
+
let issues = [] as Awaited<ReturnType<typeof validatePlanBoundary>>;
|
|
3158
|
+
|
|
3159
|
+
if (unitType === "plan-slice") {
|
|
3160
|
+
issues = await validatePlanBoundary(basePath, mid, sid);
|
|
3161
|
+
} else if (unitType === "execute-task" && tid) {
|
|
3162
|
+
issues = await validateExecuteBoundary(basePath, mid, sid, tid);
|
|
3163
|
+
} else if (unitType === "complete-slice") {
|
|
3164
|
+
issues = await validateCompleteBoundary(basePath, mid, sid);
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
if (issues.length > 0) {
|
|
3168
|
+
ctx.ui.notify(
|
|
3169
|
+
`Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`,
|
|
3170
|
+
"warning",
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
return issues;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
function buildObservabilityRepairBlock(issues: import("./observability-validator.ts").ValidationIssue[]): string {
|
|
3178
|
+
if (issues.length === 0) return "";
|
|
3179
|
+
const items = issues.map(issue => {
|
|
3180
|
+
const fileName = issue.file.split("/").pop() || issue.file;
|
|
3181
|
+
let line = `- **${fileName}**: ${issue.message}`;
|
|
3182
|
+
if (issue.suggestion) line += ` → ${issue.suggestion}`;
|
|
3183
|
+
return line;
|
|
3184
|
+
});
|
|
3185
|
+
return [
|
|
3186
|
+
"",
|
|
3187
|
+
"---",
|
|
3188
|
+
"",
|
|
3189
|
+
"## Pre-flight: Observability gaps to fix FIRST",
|
|
3190
|
+
"",
|
|
3191
|
+
"The following issues were detected in plan/summary files for this unit.",
|
|
3192
|
+
"**Read each flagged file, apply the fix described, then proceed with the unit.**",
|
|
3193
|
+
"",
|
|
3194
|
+
...items,
|
|
3195
|
+
"",
|
|
3196
|
+
"---",
|
|
3197
|
+
"",
|
|
3198
|
+
].join("\n");
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
async function recoverTimedOutUnit(
|
|
3202
|
+
ctx: ExtensionContext,
|
|
3203
|
+
pi: ExtensionAPI,
|
|
3204
|
+
unitType: string,
|
|
3205
|
+
unitId: string,
|
|
3206
|
+
reason: "idle" | "hard",
|
|
3207
|
+
): Promise<"recovered" | "paused"> {
|
|
3208
|
+
if (!currentUnit) return "paused";
|
|
3209
|
+
|
|
3210
|
+
const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
|
|
3211
|
+
const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
|
|
3212
|
+
const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
|
|
3213
|
+
|
|
3214
|
+
const recoveryKey = `${unitType}/${unitId}`;
|
|
3215
|
+
const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
|
|
3216
|
+
unitRecoveryCount.set(recoveryKey, attemptNumber);
|
|
3217
|
+
|
|
3218
|
+
if (attemptNumber > 1) {
|
|
3219
|
+
// Exponential backoff: 2^(n-1) seconds, capped at 30s
|
|
3220
|
+
const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
|
|
3221
|
+
ctx.ui.notify(
|
|
3222
|
+
`Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
|
|
3223
|
+
"info",
|
|
3224
|
+
);
|
|
3225
|
+
await new Promise(r => setTimeout(r, backoffMs));
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
if (unitType === "execute-task") {
|
|
3229
|
+
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
3230
|
+
if (!status) return "paused";
|
|
3231
|
+
|
|
3232
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3233
|
+
recovery: status,
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
|
|
3237
|
+
if (durableComplete) {
|
|
3238
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3239
|
+
phase: "finalized",
|
|
3240
|
+
recovery: status,
|
|
3241
|
+
});
|
|
3242
|
+
ctx.ui.notify(
|
|
3243
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
|
|
3244
|
+
"info",
|
|
3245
|
+
);
|
|
3246
|
+
unitRecoveryCount.delete(recoveryKey);
|
|
3247
|
+
await dispatchNextUnit(ctx, pi);
|
|
3248
|
+
return "recovered";
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
3252
|
+
const isEscalation = recoveryAttempts > 0;
|
|
3253
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3254
|
+
phase: "recovered",
|
|
3255
|
+
recovery: status,
|
|
3256
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3257
|
+
lastRecoveryReason: reason,
|
|
3258
|
+
lastProgressAt: Date.now(),
|
|
3259
|
+
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
3260
|
+
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
const steeringLines = isEscalation
|
|
3264
|
+
? [
|
|
3265
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
|
|
3266
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
3267
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3268
|
+
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
3269
|
+
"You MUST finish the durable output NOW, even if incomplete.",
|
|
3270
|
+
"Write the task summary with whatever you have accomplished so far.",
|
|
3271
|
+
"Mark the task [x] in the plan. Commit your work.",
|
|
3272
|
+
"A partial summary is infinitely better than no summary.",
|
|
3273
|
+
]
|
|
3274
|
+
: [
|
|
3275
|
+
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
|
|
3276
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
3277
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3278
|
+
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
3279
|
+
"Do not keep exploring.",
|
|
3280
|
+
"Immediately finish the required durable output for this unit.",
|
|
3281
|
+
"If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
|
|
3282
|
+
];
|
|
3283
|
+
|
|
3284
|
+
pi.sendMessage(
|
|
3285
|
+
{
|
|
3286
|
+
customType: "gsd-auto-timeout-recovery",
|
|
3287
|
+
display: verbose,
|
|
3288
|
+
content: steeringLines.join("\n"),
|
|
3289
|
+
},
|
|
3290
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
3291
|
+
);
|
|
3292
|
+
ctx.ui.notify(
|
|
3293
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
|
|
3294
|
+
"warning",
|
|
3295
|
+
);
|
|
3296
|
+
return "recovered";
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// Retries exhausted — write missing durable artifacts and advance.
|
|
3300
|
+
const diagnostic = formatExecuteTaskRecoveryStatus(status);
|
|
3301
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
3302
|
+
const skipped = mid && sid && tid
|
|
3303
|
+
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
|
|
3304
|
+
: false;
|
|
3305
|
+
|
|
3306
|
+
if (skipped) {
|
|
3307
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3308
|
+
phase: "skipped",
|
|
3309
|
+
recovery: status,
|
|
3310
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3311
|
+
lastRecoveryReason: reason,
|
|
3312
|
+
});
|
|
3313
|
+
ctx.ui.notify(
|
|
3314
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
|
|
3315
|
+
"warning",
|
|
3316
|
+
);
|
|
3317
|
+
unitRecoveryCount.delete(recoveryKey);
|
|
3318
|
+
await dispatchNextUnit(ctx, pi);
|
|
3319
|
+
return "recovered";
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
// Fallback: couldn't write skip artifacts — pause as before.
|
|
3323
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3324
|
+
phase: "paused",
|
|
3325
|
+
recovery: status,
|
|
3326
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3327
|
+
lastRecoveryReason: reason,
|
|
3328
|
+
});
|
|
3329
|
+
ctx.ui.notify(
|
|
3330
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
|
|
3331
|
+
"warning",
|
|
3332
|
+
);
|
|
3333
|
+
return "paused";
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
|
|
3337
|
+
|
|
3338
|
+
// Check if the artifact already exists on disk — agent may have written it
|
|
3339
|
+
// without signaling completion.
|
|
3340
|
+
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
3341
|
+
if (artifactPath && existsSync(artifactPath)) {
|
|
3342
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3343
|
+
phase: "finalized",
|
|
3344
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3345
|
+
lastRecoveryReason: reason,
|
|
3346
|
+
});
|
|
3347
|
+
ctx.ui.notify(
|
|
3348
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
|
|
3349
|
+
"info",
|
|
3350
|
+
);
|
|
3351
|
+
unitRecoveryCount.delete(recoveryKey);
|
|
3352
|
+
await dispatchNextUnit(ctx, pi);
|
|
3353
|
+
return "recovered";
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
3357
|
+
const isEscalation = recoveryAttempts > 0;
|
|
3358
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3359
|
+
phase: "recovered",
|
|
3360
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3361
|
+
lastRecoveryReason: reason,
|
|
3362
|
+
lastProgressAt: Date.now(),
|
|
3363
|
+
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
3364
|
+
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3367
|
+
const steeringLines = isEscalation
|
|
3368
|
+
? [
|
|
3369
|
+
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
|
|
3370
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
3371
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
|
|
3372
|
+
`Expected durable output: ${expected}.`,
|
|
3373
|
+
"You MUST write the artifact file NOW, even if incomplete.",
|
|
3374
|
+
"Write whatever you have — partial research, preliminary findings, best-effort analysis.",
|
|
3375
|
+
"A partial artifact is infinitely better than no artifact.",
|
|
3376
|
+
"If you are truly blocked, write the file with a BLOCKER section explaining why.",
|
|
3377
|
+
]
|
|
3378
|
+
: [
|
|
3379
|
+
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
|
|
3380
|
+
`You are still executing ${unitType} ${unitId}.`,
|
|
3381
|
+
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3382
|
+
`Expected durable output: ${expected}.`,
|
|
3383
|
+
"Stop broad exploration.",
|
|
3384
|
+
"Write the required artifact now.",
|
|
3385
|
+
"If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
|
|
3386
|
+
];
|
|
3387
|
+
|
|
3388
|
+
pi.sendMessage(
|
|
3389
|
+
{
|
|
3390
|
+
customType: "gsd-auto-timeout-recovery",
|
|
3391
|
+
display: verbose,
|
|
3392
|
+
content: steeringLines.join("\n"),
|
|
3393
|
+
},
|
|
3394
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
3395
|
+
);
|
|
3396
|
+
ctx.ui.notify(
|
|
3397
|
+
`${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
|
|
3398
|
+
"warning",
|
|
3399
|
+
);
|
|
3400
|
+
return "recovered";
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
// Retries exhausted — write a blocker placeholder and advance the pipeline
|
|
3404
|
+
// instead of silently stalling.
|
|
3405
|
+
const placeholder = writeBlockerPlaceholder(
|
|
3406
|
+
unitType, unitId, basePath,
|
|
3407
|
+
`${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
|
|
3408
|
+
);
|
|
3409
|
+
|
|
3410
|
+
if (placeholder) {
|
|
3411
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3412
|
+
phase: "skipped",
|
|
3413
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3414
|
+
lastRecoveryReason: reason,
|
|
3415
|
+
});
|
|
3416
|
+
ctx.ui.notify(
|
|
3417
|
+
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
|
|
3418
|
+
"warning",
|
|
3419
|
+
);
|
|
3420
|
+
unitRecoveryCount.delete(recoveryKey);
|
|
3421
|
+
await dispatchNextUnit(ctx, pi);
|
|
3422
|
+
return "recovered";
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// Fallback: couldn't resolve artifact path — pause as before.
|
|
3426
|
+
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3427
|
+
phase: "paused",
|
|
3428
|
+
recoveryAttempts: recoveryAttempts + 1,
|
|
3429
|
+
lastRecoveryReason: reason,
|
|
3430
|
+
});
|
|
3431
|
+
return "paused";
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/**
|
|
3435
|
+
* Write skip artifacts for a stuck execute-task: a blocker task summary and
|
|
3436
|
+
* the [x] checkbox in the slice plan. Returns true if artifacts were written.
|
|
3437
|
+
*/
|
|
3438
|
+
export function skipExecuteTask(
|
|
3439
|
+
base: string, mid: string, sid: string, tid: string,
|
|
3440
|
+
status: { summaryExists: boolean; taskChecked: boolean },
|
|
3441
|
+
reason: string, maxAttempts: number,
|
|
3442
|
+
): boolean {
|
|
3443
|
+
// Write a blocker task summary if missing.
|
|
3444
|
+
if (!status.summaryExists) {
|
|
3445
|
+
const tasksDir = resolveTasksDir(base, mid, sid);
|
|
3446
|
+
const sDir = resolveSlicePath(base, mid, sid);
|
|
3447
|
+
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
|
|
3448
|
+
if (!targetDir) return false;
|
|
3449
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
3450
|
+
const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
|
|
3451
|
+
const content = [
|
|
3452
|
+
`# BLOCKER — task skipped by auto-mode recovery`,
|
|
3453
|
+
``,
|
|
3454
|
+
`Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`,
|
|
3455
|
+
``,
|
|
3456
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
3457
|
+
`Review this task manually and replace this file with a real summary.`,
|
|
3458
|
+
].join("\n");
|
|
3459
|
+
writeFileSync(summaryPath, content, "utf-8");
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
// Mark [x] in the slice plan if not already checked.
|
|
3463
|
+
if (!status.taskChecked) {
|
|
3464
|
+
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
|
|
3465
|
+
if (planAbs && existsSync(planAbs)) {
|
|
3466
|
+
const planContent = readFileSync(planAbs, "utf-8");
|
|
3467
|
+
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3468
|
+
const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m");
|
|
3469
|
+
if (re.test(planContent)) {
|
|
3470
|
+
writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8");
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
return true;
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
/**
|
|
3479
|
+
* Detect whether the agent is producing work on disk by checking git for
|
|
3480
|
+
* any working-tree changes (staged, unstaged, or untracked). Returns true
|
|
3481
|
+
* if there are uncommitted changes — meaning the agent is actively working,
|
|
3482
|
+
* even though it hasn't signaled progress through runtime records.
|
|
3483
|
+
*/
|
|
3484
|
+
function detectWorkingTreeActivity(cwd: string): boolean {
|
|
3485
|
+
try {
|
|
3486
|
+
const out = execSync("git status --porcelain", {
|
|
3487
|
+
cwd,
|
|
3488
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3489
|
+
timeout: 5000,
|
|
3490
|
+
});
|
|
3491
|
+
return out.toString().trim().length > 0;
|
|
3492
|
+
} catch {
|
|
3493
|
+
return false;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
/**
|
|
3498
|
+
* Resolve the expected artifact for a non-execute-task unit to an absolute path.
|
|
3499
|
+
* Returns null for unit types that don't produce a single file (execute-task,
|
|
3500
|
+
* complete-slice, replan-slice).
|
|
3501
|
+
*/
|
|
3502
|
+
export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
|
|
3503
|
+
const parts = unitId.split("/");
|
|
3504
|
+
const mid = parts[0]!;
|
|
3505
|
+
const sid = parts[1];
|
|
3506
|
+
switch (unitType) {
|
|
3507
|
+
case "research-milestone": {
|
|
3508
|
+
const dir = resolveMilestonePath(base, mid);
|
|
3509
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
|
|
3510
|
+
}
|
|
3511
|
+
case "plan-milestone": {
|
|
3512
|
+
const dir = resolveMilestonePath(base, mid);
|
|
3513
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null;
|
|
3514
|
+
}
|
|
3515
|
+
case "research-slice": {
|
|
3516
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3517
|
+
return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null;
|
|
3518
|
+
}
|
|
3519
|
+
case "plan-slice": {
|
|
3520
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3521
|
+
return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null;
|
|
3522
|
+
}
|
|
3523
|
+
case "reassess-roadmap": {
|
|
3524
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3525
|
+
return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null;
|
|
3526
|
+
}
|
|
3527
|
+
case "run-uat": {
|
|
3528
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3529
|
+
return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
|
|
3530
|
+
}
|
|
3531
|
+
case "execute-task": {
|
|
3532
|
+
const tid = parts[2];
|
|
3533
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3534
|
+
return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
|
|
3535
|
+
}
|
|
3536
|
+
case "complete-slice": {
|
|
3537
|
+
const dir = resolveSlicePath(base, mid, sid!);
|
|
3538
|
+
return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
|
|
3539
|
+
}
|
|
3540
|
+
case "complete-milestone": {
|
|
3541
|
+
const dir = resolveMilestonePath(base, mid);
|
|
3542
|
+
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
|
3543
|
+
}
|
|
3544
|
+
case "fix-merge":
|
|
3545
|
+
return null;
|
|
3546
|
+
default:
|
|
3547
|
+
return null;
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
/**
|
|
3552
|
+
* Check whether the expected artifact(s) for a unit exist on disk.
|
|
3553
|
+
* Returns true if all required artifacts exist, or if the unit type has no
|
|
3554
|
+
* single verifiable artifact (e.g., replan-slice).
|
|
3555
|
+
*
|
|
3556
|
+
* complete-slice requires both SUMMARY and UAT files — verifying only
|
|
3557
|
+
* the summary allowed the unit to be marked complete when the LLM
|
|
3558
|
+
* skipped writing the UAT file (see #176).
|
|
3559
|
+
*/
|
|
3560
|
+
export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
|
|
3561
|
+
// Clear stale directory listing cache so artifact checks see fresh disk state (#431)
|
|
3562
|
+
clearPathCache();
|
|
3563
|
+
|
|
3564
|
+
// fix-merge has no file artifact — verify by checking git state
|
|
3565
|
+
if (unitType === "fix-merge") {
|
|
3566
|
+
const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
|
3567
|
+
if (unmerged && unmerged.trim()) return false;
|
|
3568
|
+
if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
|
|
3569
|
+
if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
|
|
3570
|
+
return true;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
3574
|
+
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
|
|
3575
|
+
// For all other types, null means the parent directory is missing on disk
|
|
3576
|
+
// — treat as stale completion state so the key gets evicted (#313).
|
|
3577
|
+
if (!absPath) return unitType === "replan-slice";
|
|
3578
|
+
if (!existsSync(absPath)) return false;
|
|
3579
|
+
|
|
3580
|
+
// execute-task must also have its checkbox marked [x] in the slice plan
|
|
3581
|
+
if (unitType === "execute-task") {
|
|
3582
|
+
const parts = unitId.split("/");
|
|
3583
|
+
const mid = parts[0];
|
|
3584
|
+
const sid = parts[1];
|
|
3585
|
+
const tid = parts[2];
|
|
3586
|
+
if (mid && sid && tid) {
|
|
3587
|
+
const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
|
|
3588
|
+
if (planAbs && existsSync(planAbs)) {
|
|
3589
|
+
const planContent = readFileSync(planAbs, "utf-8");
|
|
3590
|
+
const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3591
|
+
const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m");
|
|
3592
|
+
if (!re.test(planContent)) return false;
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
// complete-slice must also produce a UAT file AND mark the slice [x] in the roadmap.
|
|
3598
|
+
// Without the roadmap check, a crash after writing SUMMARY+UAT but before updating
|
|
3599
|
+
// the roadmap causes an infinite skip loop: the idempotency key says "done" but the
|
|
3600
|
+
// state machine keeps returning the same complete-slice unit (roadmap still shows
|
|
3601
|
+
// the slice incomplete), so dispatchNextUnit recurses forever.
|
|
3602
|
+
if (unitType === "complete-slice") {
|
|
3603
|
+
const parts = unitId.split("/");
|
|
3604
|
+
const mid = parts[0];
|
|
3605
|
+
const sid = parts[1];
|
|
3606
|
+
if (mid && sid) {
|
|
3607
|
+
const dir = resolveSlicePath(base, mid, sid);
|
|
3608
|
+
if (dir) {
|
|
3609
|
+
const uatPath = join(dir, buildSliceFileName(sid, "UAT"));
|
|
3610
|
+
if (!existsSync(uatPath)) return false;
|
|
3611
|
+
}
|
|
3612
|
+
// Verify the roadmap has the slice marked [x]. If not, the completion
|
|
3613
|
+
// record is stale — the unit must re-run to update the roadmap.
|
|
3614
|
+
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
3615
|
+
if (roadmapFile && existsSync(roadmapFile)) {
|
|
3616
|
+
try {
|
|
3617
|
+
const roadmapContent = readFileSync(roadmapFile, "utf-8");
|
|
3618
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
3619
|
+
const slice = roadmap.slices.find(s => s.id === sid);
|
|
3620
|
+
if (slice && !slice.done) return false;
|
|
3621
|
+
} catch { /* corrupt roadmap — be lenient and treat as verified */ }
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
return true;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* Write a placeholder artifact so the pipeline can advance past a stuck unit.
|
|
3631
|
+
* Returns the relative path written, or null if the path couldn't be resolved.
|
|
3632
|
+
*/
|
|
3633
|
+
export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
|
|
3634
|
+
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
3635
|
+
if (!absPath) return null;
|
|
3636
|
+
const dir = dirname(absPath);
|
|
3637
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3638
|
+
const content = [
|
|
3639
|
+
`# BLOCKER — auto-mode recovery failed`,
|
|
3640
|
+
``,
|
|
3641
|
+
`Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`,
|
|
3642
|
+
``,
|
|
3643
|
+
`**Reason**: ${reason}`,
|
|
3644
|
+
``,
|
|
3645
|
+
`This placeholder was written by auto-mode so the pipeline can advance.`,
|
|
3646
|
+
`Review and replace this file before relying on downstream artifacts.`,
|
|
3647
|
+
].join("\n");
|
|
3648
|
+
writeFileSync(absPath, content, "utf-8");
|
|
3649
|
+
return diagnoseExpectedArtifact(unitType, unitId, base);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
|
|
3653
|
+
const parts = unitId.split("/");
|
|
3654
|
+
const mid = parts[0];
|
|
3655
|
+
const sid = parts[1];
|
|
3656
|
+
switch (unitType) {
|
|
3657
|
+
case "research-milestone":
|
|
3658
|
+
return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
|
|
3659
|
+
case "plan-milestone":
|
|
3660
|
+
return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`;
|
|
3661
|
+
case "research-slice":
|
|
3662
|
+
return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`;
|
|
3663
|
+
case "plan-slice":
|
|
3664
|
+
return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
|
|
3665
|
+
case "execute-task": {
|
|
3666
|
+
const tid = parts[2];
|
|
3667
|
+
return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
|
|
3668
|
+
}
|
|
3669
|
+
case "complete-slice":
|
|
3670
|
+
return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`;
|
|
3671
|
+
case "replan-slice":
|
|
3672
|
+
return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`;
|
|
3673
|
+
case "reassess-roadmap":
|
|
3674
|
+
return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`;
|
|
3675
|
+
case "run-uat":
|
|
3676
|
+
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
|
|
3677
|
+
case "complete-milestone":
|
|
3678
|
+
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
|
|
3679
|
+
case "fix-merge":
|
|
3680
|
+
return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
|
|
3681
|
+
default:
|
|
3682
|
+
return null;
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/**
|
|
3687
|
+
* Build concrete, manual remediation steps for a loop-detected unit failure.
|
|
3688
|
+
* These are shown when automatic reconciliation is not possible.
|
|
3689
|
+
*/
|
|
3690
|
+
export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
|
|
3691
|
+
const parts = unitId.split("/");
|
|
3692
|
+
const mid = parts[0];
|
|
3693
|
+
const sid = parts[1];
|
|
3694
|
+
const tid = parts[2];
|
|
3695
|
+
switch (unitType) {
|
|
3696
|
+
case "execute-task": {
|
|
3697
|
+
if (!mid || !sid || !tid) break;
|
|
3698
|
+
const planRel = relSliceFile(base, mid, sid, "PLAN");
|
|
3699
|
+
const summaryRel = relTaskFile(base, mid, sid, tid, "SUMMARY");
|
|
3700
|
+
return [
|
|
3701
|
+
` 1. Write ${summaryRel} (even a partial summary is sufficient to unblock the pipeline)`,
|
|
3702
|
+
` 2. Mark ${tid} [x] in ${planRel}: change "- [ ] **${tid}:" → "- [x] **${tid}:"`,
|
|
3703
|
+
` 3. Run \`gsd doctor\` to reconcile .gsd/ state`,
|
|
3704
|
+
` 4. Resume auto-mode — it will pick up from the next task`,
|
|
3705
|
+
].join("\n");
|
|
3706
|
+
}
|
|
3707
|
+
case "plan-slice":
|
|
3708
|
+
case "research-slice": {
|
|
3709
|
+
if (!mid || !sid) break;
|
|
3710
|
+
const artifactRel = unitType === "plan-slice"
|
|
3711
|
+
? relSliceFile(base, mid, sid, "PLAN")
|
|
3712
|
+
: relSliceFile(base, mid, sid, "RESEARCH");
|
|
3713
|
+
return [
|
|
3714
|
+
` 1. Write ${artifactRel} manually (or with the LLM in interactive mode)`,
|
|
3715
|
+
` 2. Run \`gsd doctor\` to reconcile .gsd/ state`,
|
|
3716
|
+
` 3. Resume auto-mode`,
|
|
3717
|
+
].join("\n");
|
|
3718
|
+
}
|
|
3719
|
+
case "complete-slice": {
|
|
3720
|
+
if (!mid || !sid) break;
|
|
3721
|
+
return [
|
|
3722
|
+
` 1. Write the slice summary and UAT file for ${sid} in ${relSlicePath(base, mid, sid)}`,
|
|
3723
|
+
` 2. Mark ${sid} [x] in ${relMilestoneFile(base, mid, "ROADMAP")}`,
|
|
3724
|
+
` 3. Run \`gsd doctor\` to reconcile .gsd/ state`,
|
|
3725
|
+
` 4. Resume auto-mode`,
|
|
3726
|
+
].join("\n");
|
|
3727
|
+
}
|
|
3728
|
+
default:
|
|
3729
|
+
break;
|
|
3730
|
+
}
|
|
3731
|
+
return null;
|
|
3732
|
+
}
|