savepoint 1.0.2 → 1.0.3
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/.claude/settings.local.json +12 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +37 -36
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +9 -16
- package/AGENTS.md +38 -23
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +40 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +41 -5
- package/internal/board/column.go +44 -13
- package/internal/board/column_test.go +5 -2
- package/internal/board/detail.go +0 -47
- package/internal/board/epic_panel.go +118 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +325 -215
- package/internal/board/update_test.go +299 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +12 -2
- package/internal/board/watch.go +35 -5
- package/internal/buildtool/main.go +2 -10
- package/internal/buildtool/main_test.go +46 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +21 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/write.go +85 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +101 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6 +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/savepoint +0 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint.exe +0 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
package doctor
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/opencode/savepoint/internal/data"
|
|
11
|
+
"gopkg.in/yaml.v3"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// CheckConfig validates config.yml: exists, valid YAML, required fields present.
|
|
15
|
+
func CheckConfig(root string) error {
|
|
16
|
+
configPath := filepath.Join(root, "config.yml")
|
|
17
|
+
raw, err := os.ReadFile(configPath)
|
|
18
|
+
if os.IsNotExist(err) {
|
|
19
|
+
return fmt.Errorf("config.yml not found: %w", data.ErrConfigNotFound)
|
|
20
|
+
}
|
|
21
|
+
if err != nil {
|
|
22
|
+
return fmt.Errorf("config.yml unreadable: %w", err)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var fields map[string]any
|
|
26
|
+
if err := yaml.Unmarshal(raw, &fields); err != nil {
|
|
27
|
+
return fmt.Errorf("config.yml invalid YAML: %w", err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if _, ok := fields["quality_gates"]; !ok {
|
|
31
|
+
return fmt.Errorf("config.yml missing required field: quality_gates")
|
|
32
|
+
}
|
|
33
|
+
if _, ok := fields["theme"]; !ok {
|
|
34
|
+
return fmt.Errorf("config.yml missing required field: theme")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// CheckRouter validates router.md: valid state name, release/epic directories exist.
|
|
41
|
+
// epicFilter, if non-empty, skips directory checks when the router epic doesn't match.
|
|
42
|
+
func CheckRouter(root, epicFilter string, overrides ...DoctorDependencies) error {
|
|
43
|
+
deps := doctorDependencies(overrides)
|
|
44
|
+
routerPath := filepath.Join(root, "router.md")
|
|
45
|
+
raw, err := os.ReadFile(routerPath)
|
|
46
|
+
if os.IsNotExist(err) {
|
|
47
|
+
return fmt.Errorf("router.md not found: %w", data.ErrConfigNotFound)
|
|
48
|
+
}
|
|
49
|
+
if err != nil {
|
|
50
|
+
return fmt.Errorf("router.md unreadable: %w", err)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
state, err := deps.RouterReader.ReadState(string(raw))
|
|
54
|
+
if err != nil {
|
|
55
|
+
return fmt.Errorf("router.md invalid state block: %w", err)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if epicFilter != "" && state.Epic != epicFilter {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if state.Release != "" && state.Release != "none" {
|
|
63
|
+
releasePath := filepath.Join(root, "releases", state.Release)
|
|
64
|
+
if _, err := os.Stat(releasePath); os.IsNotExist(err) {
|
|
65
|
+
return fmt.Errorf("router.md release %q directory not found", state.Release)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if state.Epic != "" && state.Epic != "none" {
|
|
70
|
+
if state.Release == "" || state.Release == "none" {
|
|
71
|
+
return fmt.Errorf("router.md has epic %q but no release", state.Epic)
|
|
72
|
+
}
|
|
73
|
+
epicPath := filepath.Join(root, "releases", state.Release, "epics", state.Epic)
|
|
74
|
+
if _, err := os.Stat(epicPath); os.IsNotExist(err) {
|
|
75
|
+
return fmt.Errorf("router.md epic %q directory not found", state.Epic)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Problem describes a single issue found during a structure check.
|
|
83
|
+
type Problem struct {
|
|
84
|
+
File string
|
|
85
|
+
Line int
|
|
86
|
+
Message string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func (p Problem) Error() string {
|
|
90
|
+
if p.Line > 0 {
|
|
91
|
+
return fmt.Sprintf("%s:%d: %s", p.File, p.Line, p.Message)
|
|
92
|
+
}
|
|
93
|
+
if p.File != "" {
|
|
94
|
+
return fmt.Sprintf("%s: %s", p.File, p.Message)
|
|
95
|
+
}
|
|
96
|
+
return p.Message
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CheckStructure validates release/epic/task structure and YAML across the project.
|
|
100
|
+
// epicFilter, if non-empty, restricts checks to matching epics.
|
|
101
|
+
func CheckStructure(root string, epicFilter string, overrides ...DoctorDependencies) []Problem {
|
|
102
|
+
deps := doctorDependencies(overrides)
|
|
103
|
+
var problems []Problem
|
|
104
|
+
|
|
105
|
+
releasesPath := filepath.Join(root, "releases")
|
|
106
|
+
if _, err := os.Stat(releasesPath); os.IsNotExist(err) {
|
|
107
|
+
problems = append(problems, Problem{File: releasesPath, Message: "releases directory not found"})
|
|
108
|
+
return problems
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
releases, err := deps.Discoverer.ListReleases(root)
|
|
112
|
+
if err != nil {
|
|
113
|
+
problems = append(problems, Problem{File: releasesPath, Message: fmt.Sprintf("listing releases: %v", err)})
|
|
114
|
+
return problems
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if len(releases) == 0 {
|
|
118
|
+
problems = append(problems, Problem{File: releasesPath, Message: "no release directories found"})
|
|
119
|
+
return problems
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for _, release := range releases {
|
|
123
|
+
checkReleasePRD(release.Path, release.ID, deps.Parser, &problems)
|
|
124
|
+
|
|
125
|
+
epics, err := deps.Discoverer.ListEpics(root, release.ID)
|
|
126
|
+
if err != nil {
|
|
127
|
+
problems = append(problems, Problem{
|
|
128
|
+
File: filepath.Join(release.Path, "epics"),
|
|
129
|
+
Message: fmt.Sprintf("listing epics in release %q: %v", release.ID, err),
|
|
130
|
+
})
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for _, epic := range epics {
|
|
135
|
+
if epicFilter != "" && epic.ID != epicFilter && !strings.HasPrefix(epic.ID, epicFilter) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
checkEpicDetail(epic.Path, epic.ID, deps.Parser, &problems)
|
|
140
|
+
|
|
141
|
+
tasks, err := deps.Discoverer.ListTasks(root, release.ID, epic.ID)
|
|
142
|
+
if err != nil {
|
|
143
|
+
problems = append(problems, Problem{
|
|
144
|
+
File: filepath.Join(epic.Path, "tasks"),
|
|
145
|
+
Message: fmt.Sprintf("listing tasks in epic %q: %v", epic.ID, err),
|
|
146
|
+
})
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for _, task := range tasks {
|
|
151
|
+
checkTaskFile(task.Path, deps.Parser, &problems)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return problems
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func checkReleasePRD(releasePath string, releaseID string, parser taskParser, problems *[]Problem) {
|
|
160
|
+
prdPath := filepath.Join(releasePath, releaseID+"-PRD.md")
|
|
161
|
+
raw, err := os.ReadFile(prdPath)
|
|
162
|
+
if os.IsNotExist(err) {
|
|
163
|
+
*problems = append(*problems, Problem{File: prdPath, Message: "release PRD file not found"})
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if err != nil {
|
|
167
|
+
*problems = append(*problems, Problem{File: prdPath, Message: fmt.Sprintf("unreadable: %v", err)})
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
validateFrontmatter(prdPath, string(raw), parser, problems)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func checkEpicDetail(epicPath string, epicID string, parser taskParser, problems *[]Problem) {
|
|
174
|
+
prefix := extractPrefix(epicID)
|
|
175
|
+
detailPath := filepath.Join(epicPath, prefix+"-Detail.md")
|
|
176
|
+
raw, err := os.ReadFile(detailPath)
|
|
177
|
+
if os.IsNotExist(err) {
|
|
178
|
+
*problems = append(*problems, Problem{File: detailPath, Message: "epic detail file not found"})
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if err != nil {
|
|
182
|
+
*problems = append(*problems, Problem{File: detailPath, Message: fmt.Sprintf("unreadable: %v", err)})
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
validateFrontmatter(detailPath, string(raw), parser, problems)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func extractPrefix(epicID string) string {
|
|
189
|
+
if idx := strings.IndexByte(epicID, '-'); idx != -1 {
|
|
190
|
+
return epicID[:idx]
|
|
191
|
+
}
|
|
192
|
+
return epicID
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func checkTaskFile(path string, parser taskParser, problems *[]Problem) {
|
|
196
|
+
raw, err := os.ReadFile(path)
|
|
197
|
+
if err != nil {
|
|
198
|
+
*problems = append(*problems, Problem{File: path, Message: fmt.Sprintf("unreadable: %v", err)})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
content := string(raw)
|
|
203
|
+
fm, err := parser.ParseFrontmatter(content)
|
|
204
|
+
if err != nil {
|
|
205
|
+
line := extractYAMLLine(err)
|
|
206
|
+
*problems = append(*problems, Problem{File: path, Line: line, Message: fmt.Sprintf("invalid frontmatter: %v", err)})
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
checkRequiredString(fm, path, "id", problems)
|
|
211
|
+
checkRequiredString(fm, path, "status", problems)
|
|
212
|
+
checkRequiredString(fm, path, "objective", problems)
|
|
213
|
+
checkDependsOn(fm, path, problems)
|
|
214
|
+
|
|
215
|
+
if !hasAcceptanceCriteria(content) {
|
|
216
|
+
*problems = append(*problems, Problem{File: path, Message: "task missing ## Acceptance Criteria section"})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func checkRequiredString(fm map[string]any, path, field string, problems *[]Problem) {
|
|
221
|
+
val, ok := fm[field]
|
|
222
|
+
if !ok {
|
|
223
|
+
*problems = append(*problems, Problem{File: path, Message: fmt.Sprintf("task missing required frontmatter field: %s", field)})
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
s, ok := val.(string)
|
|
227
|
+
if !ok || s == "" {
|
|
228
|
+
*problems = append(*problems, Problem{File: path, Message: fmt.Sprintf("task frontmatter field %q must be a non-empty string", field)})
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func checkDependsOn(fm map[string]any, path string, problems *[]Problem) {
|
|
233
|
+
val, ok := fm["depends_on"]
|
|
234
|
+
if !ok {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
switch val.(type) {
|
|
238
|
+
case []any, []string:
|
|
239
|
+
default:
|
|
240
|
+
*problems = append(*problems, Problem{File: path, Message: "task frontmatter field depends_on must be a list"})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func hasAcceptanceCriteria(content string) bool {
|
|
245
|
+
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
|
246
|
+
idx := strings.Index(normalized, "## Acceptance Criteria")
|
|
247
|
+
if idx == -1 {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
section := normalized[idx+len("## Acceptance Criteria"):]
|
|
251
|
+
if next := strings.Index(section, "\n## "); next != -1 {
|
|
252
|
+
section = section[:next]
|
|
253
|
+
}
|
|
254
|
+
section = strings.TrimSpace(section)
|
|
255
|
+
return section != ""
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func validateFrontmatter(path, content string, parser taskParser, problems *[]Problem) {
|
|
259
|
+
_, err := parser.ParseFrontmatter(content)
|
|
260
|
+
if err != nil {
|
|
261
|
+
line := extractYAMLLine(err)
|
|
262
|
+
*problems = append(*problems, Problem{File: path, Line: line, Message: fmt.Sprintf("invalid frontmatter: %v", err)})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// taskDep describes a parsed task's dependency information.
|
|
267
|
+
type taskDep struct {
|
|
268
|
+
File string
|
|
269
|
+
ID string
|
|
270
|
+
DependsOn []string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// CheckDependencies validates task dependency integrity:
|
|
274
|
+
// missing deps, duplicate IDs, and dependency cycles.
|
|
275
|
+
// epicFilter restricts checks to matching epics if non-empty.
|
|
276
|
+
func CheckDependencies(root string, epicFilter string, overrides ...DoctorDependencies) []Problem {
|
|
277
|
+
deps := doctorDependencies(overrides)
|
|
278
|
+
var problems []Problem
|
|
279
|
+
|
|
280
|
+
releases, err := deps.Discoverer.ListReleases(root)
|
|
281
|
+
if err != nil {
|
|
282
|
+
problems = append(problems, Problem{Message: fmt.Sprintf("listing releases: %v", err)})
|
|
283
|
+
return problems
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var allTasks []taskDep
|
|
287
|
+
idSet := make(map[string]string) // id -> first file seen
|
|
288
|
+
|
|
289
|
+
for _, release := range releases {
|
|
290
|
+
epics, err := deps.Discoverer.ListEpics(root, release.ID)
|
|
291
|
+
if err != nil {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
for _, epic := range epics {
|
|
295
|
+
if epicFilter != "" && epic.ID != epicFilter && !strings.HasPrefix(epic.ID, epicFilter) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
tasks, err := deps.Discoverer.ListTasks(root, release.ID, epic.ID)
|
|
299
|
+
if err != nil {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
for _, t := range tasks {
|
|
303
|
+
td := parseTaskDep(t.Path, deps.Parser)
|
|
304
|
+
if td == nil {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
allTasks = append(allTasks, *td)
|
|
308
|
+
if existing, ok := idSet[td.ID]; ok {
|
|
309
|
+
problems = append(problems, Problem{
|
|
310
|
+
File: td.File,
|
|
311
|
+
Message: fmt.Sprintf("duplicate task ID %q (first seen in %s)", td.ID, existing),
|
|
312
|
+
})
|
|
313
|
+
} else {
|
|
314
|
+
idSet[td.ID] = td.File
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check for missing dependencies and cycles
|
|
321
|
+
graph := make(map[string][]string) // id -> list of dependencies
|
|
322
|
+
idToFile := make(map[string]string)
|
|
323
|
+
|
|
324
|
+
for _, td := range allTasks {
|
|
325
|
+
idToFile[td.ID] = td.File
|
|
326
|
+
graph[td.ID] = td.DependsOn
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for _, td := range allTasks {
|
|
330
|
+
for _, dep := range td.DependsOn {
|
|
331
|
+
if _, exists := idSet[dep]; !exists {
|
|
332
|
+
problems = append(problems, Problem{
|
|
333
|
+
File: td.File,
|
|
334
|
+
Message: fmt.Sprintf("depends_on references non-existent task %q", dep),
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Cycle detection using DFS
|
|
341
|
+
cycleProblems := detectCycles(graph, idToFile)
|
|
342
|
+
problems = append(problems, cycleProblems...)
|
|
343
|
+
|
|
344
|
+
return problems
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func parseTaskDep(path string, parser taskParser) *taskDep {
|
|
348
|
+
raw, err := os.ReadFile(path)
|
|
349
|
+
if err != nil {
|
|
350
|
+
return nil
|
|
351
|
+
}
|
|
352
|
+
fm, err := parser.ParseFrontmatter(string(raw))
|
|
353
|
+
if err != nil {
|
|
354
|
+
return nil
|
|
355
|
+
}
|
|
356
|
+
id, _ := fm["id"].(string)
|
|
357
|
+
if id == "" {
|
|
358
|
+
return nil
|
|
359
|
+
}
|
|
360
|
+
var deps []string
|
|
361
|
+
switch v := fm["depends_on"].(type) {
|
|
362
|
+
case []any:
|
|
363
|
+
for _, d := range v {
|
|
364
|
+
if s, ok := d.(string); ok {
|
|
365
|
+
deps = append(deps, s)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
case []string:
|
|
369
|
+
deps = v
|
|
370
|
+
}
|
|
371
|
+
return &taskDep{
|
|
372
|
+
File: path,
|
|
373
|
+
ID: id,
|
|
374
|
+
DependsOn: deps,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// detectCycles runs DFS on the dependency graph and returns cycle problems.
|
|
379
|
+
// Uses a path stack to accurately reconstruct cycle paths (avoids parent-map
|
|
380
|
+
// overwrite issues that produced inaccurate paths).
|
|
381
|
+
func detectCycles(graph map[string][]string, idToFile map[string]string) []Problem {
|
|
382
|
+
const (
|
|
383
|
+
white = 0
|
|
384
|
+
gray = 1
|
|
385
|
+
black = 2
|
|
386
|
+
)
|
|
387
|
+
color := make(map[string]int)
|
|
388
|
+
path := make([]string, 0)
|
|
389
|
+
|
|
390
|
+
for id := range graph {
|
|
391
|
+
color[id] = white
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
var problems []Problem
|
|
395
|
+
|
|
396
|
+
var dfs func(id string)
|
|
397
|
+
dfs = func(id string) {
|
|
398
|
+
color[id] = gray
|
|
399
|
+
path = append(path, id)
|
|
400
|
+
for _, dep := range graph[id] {
|
|
401
|
+
switch color[dep] {
|
|
402
|
+
case white:
|
|
403
|
+
dfs(dep)
|
|
404
|
+
case gray:
|
|
405
|
+
cycleStart := -1
|
|
406
|
+
for i, n := range path {
|
|
407
|
+
if n == dep {
|
|
408
|
+
cycleStart = i
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if cycleStart >= 0 {
|
|
413
|
+
cycle := path[cycleStart:]
|
|
414
|
+
cyclePath := make([]string, 0, len(cycle))
|
|
415
|
+
for _, cid := range cycle {
|
|
416
|
+
if f, ok := idToFile[cid]; ok {
|
|
417
|
+
cyclePath = append(cyclePath, f)
|
|
418
|
+
} else {
|
|
419
|
+
cyclePath = append(cyclePath, cid)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
problems = append(problems, Problem{
|
|
423
|
+
Message: fmt.Sprintf("dependency cycle detected: %s", strings.Join(cyclePath, " → ")),
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
path = path[:len(path)-1]
|
|
429
|
+
color[id] = black
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for id := range graph {
|
|
433
|
+
if color[id] == white {
|
|
434
|
+
dfs(id)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return problems
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// CheckAuditState finds audit proposal files without matching audit-pending state in the router.
|
|
441
|
+
func CheckAuditState(root string, overrides ...DoctorDependencies) []Problem {
|
|
442
|
+
deps := doctorDependencies(overrides)
|
|
443
|
+
var problems []Problem
|
|
444
|
+
|
|
445
|
+
routerPath := filepath.Join(root, "router.md")
|
|
446
|
+
raw, err := os.ReadFile(routerPath)
|
|
447
|
+
if err != nil {
|
|
448
|
+
return problems
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
state, err := deps.RouterReader.ReadState(string(raw))
|
|
452
|
+
if err != nil {
|
|
453
|
+
return problems
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
releases, err := deps.Discoverer.ListReleases(root)
|
|
457
|
+
if err != nil {
|
|
458
|
+
return problems
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for _, release := range releases {
|
|
462
|
+
epics, err := deps.Discoverer.ListEpics(root, release.ID)
|
|
463
|
+
if err != nil {
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
for _, epic := range epics {
|
|
467
|
+
prefix := extractPrefix(epic.ID)
|
|
468
|
+
auditPath := filepath.Join(epic.Path, prefix+"-Audit.md")
|
|
469
|
+
if _, err := os.Stat(auditPath); os.IsNotExist(err) {
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
if state.State != "audit-pending" || state.Epic != epic.ID {
|
|
473
|
+
problems = append(problems, Problem{
|
|
474
|
+
File: auditPath,
|
|
475
|
+
Message: fmt.Sprintf("audit proposal exists but router state is %q (epic: %q) — expected audit-pending for %q", state.State, state.Epic, epic.ID),
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return problems
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// CheckOrphans finds tasks whose epic prefix in their ID does not match any existing epic directory.
|
|
485
|
+
func CheckOrphans(root string, overrides ...DoctorDependencies) []Problem {
|
|
486
|
+
deps := doctorDependencies(overrides)
|
|
487
|
+
var problems []Problem
|
|
488
|
+
|
|
489
|
+
existingEpics := make(map[string]bool)
|
|
490
|
+
releasesPath := filepath.Join(root, "releases")
|
|
491
|
+
releaseDirs, err := deps.Discoverer.ListRootDirs(releasesPath)
|
|
492
|
+
if err != nil {
|
|
493
|
+
problems = append(problems, Problem{File: releasesPath, Message: fmt.Sprintf("listing releases: %v", err)})
|
|
494
|
+
return problems
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for _, release := range releaseDirs {
|
|
498
|
+
epicsPath := filepath.Join(releasesPath, release, "epics")
|
|
499
|
+
epics, err := deps.Discoverer.ListRootDirs(epicsPath)
|
|
500
|
+
if err != nil {
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
for _, epic := range epics {
|
|
504
|
+
existingEpics[epic] = true
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Collect all tasks and check their epic references
|
|
509
|
+
allReleases, err := deps.Discoverer.ListReleases(root)
|
|
510
|
+
if err != nil {
|
|
511
|
+
return problems
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for _, release := range allReleases {
|
|
515
|
+
epics, err := deps.Discoverer.ListEpics(root, release.ID)
|
|
516
|
+
if err != nil {
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
for _, epic := range epics {
|
|
520
|
+
tasks, err := deps.Discoverer.ListTasks(root, release.ID, epic.ID)
|
|
521
|
+
if err != nil {
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
for _, t := range tasks {
|
|
525
|
+
raw, err := os.ReadFile(t.Path)
|
|
526
|
+
if err != nil {
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
fm, err := deps.Parser.ParseFrontmatter(string(raw))
|
|
530
|
+
if err != nil {
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
id, _ := fm["id"].(string)
|
|
534
|
+
if id == "" {
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
idx := strings.IndexByte(id, '/')
|
|
538
|
+
if idx == -1 {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
taskEpic := id[:idx]
|
|
542
|
+
if !existingEpics[taskEpic] {
|
|
543
|
+
problems = append(problems, Problem{
|
|
544
|
+
File: t.Path,
|
|
545
|
+
Message: fmt.Sprintf("orphaned task: epic %q does not exist in any release — consider moving to .savepoint/orphans/", taskEpic),
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return problems
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
func extractYAMLLine(err error) int {
|
|
556
|
+
s := err.Error()
|
|
557
|
+
const prefix = "yaml: line "
|
|
558
|
+
if idx := strings.Index(s, prefix); idx != -1 {
|
|
559
|
+
rest := s[idx+len(prefix):]
|
|
560
|
+
if end := strings.IndexByte(rest, ':'); end != -1 {
|
|
561
|
+
if line, err := strconv.Atoi(rest[:end]); err == nil {
|
|
562
|
+
return line
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return 0
|
|
567
|
+
}
|