Skip to content

Commit 8f321be

Browse files
committed
feat: add phase next command, scoped unresolved query, auto-activate on delivers
- Add `spec-graph phase next [--activate]` command that finds the next eligible phase in the active plan and optionally activates it - Add `--phase` flag to `query unresolved` to scope results to entities covered by a specific phase - Auto-transition arch entities from draft to active when a `delivers` relation is added targeting them - Fix PLN status bug in spec-planner skill (use --status active flag instead of metadata) - Update spec-graph and spec-executor skills with new CLI reference and auto-activation documentation
1 parent 774f489 commit 8f321be

8 files changed

Lines changed: 737 additions & 23 deletions

File tree

internal/cli/phase.go

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"sort"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/tyeongkim/spec-graph/internal/index"
11+
"github.com/tyeongkim/spec-graph/internal/model"
12+
spectoml "github.com/tyeongkim/spec-graph/internal/toml"
13+
)
14+
15+
var phaseCmd = &cobra.Command{
16+
Use: "phase",
17+
Short: "Phase lifecycle commands",
18+
}
19+
20+
type PhaseNextResponse struct {
21+
Phase PhaseNextDetail `json:"phase"`
22+
Scope PhaseNextScope `json:"scope"`
23+
Activated bool `json:"activated"`
24+
}
25+
26+
type PhaseNextDetail struct {
27+
ID string `json:"id"`
28+
Title string `json:"title"`
29+
Status string `json:"status"`
30+
Goal string `json:"goal"`
31+
Order float64 `json:"order"`
32+
PredecessorsResolved bool `json:"predecessors_resolved"`
33+
Metadata json.RawMessage `json:"metadata"`
34+
}
35+
36+
type PhaseNextScope struct {
37+
Total int `json:"total"`
38+
Delivered int `json:"delivered"`
39+
Remaining []string `json:"remaining"`
40+
}
41+
42+
var phaseNextCmd = &cobra.Command{
43+
Use: "next",
44+
Short: "Find and optionally activate the next eligible phase in the active plan",
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
activate, _ := cmd.Flags().GetBool("activate")
47+
48+
activePlanID, err := findActivePlan()
49+
if err != nil {
50+
handleError(cmd, err)
51+
}
52+
53+
planPhaseIDs := collectPlanPhases(activePlanID)
54+
if len(planPhaseIDs) == 0 {
55+
handleError(cmd, &model.ErrInvalidInput{
56+
Message: fmt.Sprintf("active plan %s has no phases", activePlanID),
57+
})
58+
}
59+
60+
phases := buildPhaseInfoMap(planPhaseIDs)
61+
predecessors := buildPredecessorMap(planPhaseIDs)
62+
nextID, nextPhase := selectNextPhase(phases, predecessors)
63+
64+
if nextID == "" {
65+
handleError(cmd, &model.ErrInvalidInput{
66+
Message: "no eligible next phase found; all phases are resolved or have unresolved predecessors",
67+
})
68+
}
69+
70+
scope := computePhaseScope(nextID)
71+
72+
activated := false
73+
finalStatus := string(nextPhase.status)
74+
if activate && nextPhase.status == model.EntityStatusDraft {
75+
if err := activatePhase(cmd, nextID); err != nil {
76+
handleError(cmd, err)
77+
}
78+
activated = true
79+
finalStatus = string(model.EntityStatusActive)
80+
}
81+
82+
goal := extractGoal(nextPhase.record.Metadata)
83+
84+
response := PhaseNextResponse{
85+
Phase: PhaseNextDetail{
86+
ID: nextID,
87+
Title: nextPhase.record.Title,
88+
Status: finalStatus,
89+
Goal: goal,
90+
Order: nextPhase.order,
91+
PredecessorsResolved: true,
92+
Metadata: json.RawMessage(nextPhase.record.Metadata),
93+
},
94+
Scope: scope,
95+
Activated: activated,
96+
}
97+
98+
writeJSON(cmd, response)
99+
return nil
100+
},
101+
}
102+
103+
func findActivePlan() (string, error) {
104+
planRecs, err := queryIndex.ListEntities(index.EntityFilters{
105+
Type: string(model.EntityTypePlan),
106+
Status: string(model.EntityStatusActive),
107+
})
108+
if err != nil {
109+
return "", fmt.Errorf("list plans: %w", err)
110+
}
111+
if len(planRecs) == 0 {
112+
return "", &model.ErrInvalidInput{
113+
Message: "no active plan found; create and activate a plan first",
114+
}
115+
}
116+
117+
return planRecs[0].ID, nil
118+
}
119+
120+
func collectPlanPhases(planID string) map[string]bool {
121+
allPhaseRecs, err := queryIndex.ListEntities(index.EntityFilters{
122+
Type: string(model.EntityTypePhase),
123+
})
124+
if err != nil {
125+
return nil
126+
}
127+
128+
result := make(map[string]bool)
129+
for _, rec := range allPhaseRecs {
130+
rels, relErr := queryIndex.GetRelationsByEntity(rec.ID)
131+
if relErr != nil {
132+
continue
133+
}
134+
for _, rel := range rels {
135+
if rel.FromID == rec.ID && rel.Type == string(model.RelationBelongsTo) && rel.ToID == planID {
136+
result[rec.ID] = true
137+
break
138+
}
139+
}
140+
}
141+
142+
return result
143+
}
144+
145+
type phaseInfo struct {
146+
record index.EntityRecord
147+
order float64
148+
status model.EntityStatus
149+
}
150+
151+
func buildPhaseInfoMap(planPhaseIDs map[string]bool) map[string]*phaseInfo {
152+
allPhaseRecs, _ := queryIndex.ListEntities(index.EntityFilters{
153+
Type: string(model.EntityTypePhase),
154+
})
155+
156+
phases := make(map[string]*phaseInfo)
157+
for _, rec := range allPhaseRecs {
158+
if !planPhaseIDs[rec.ID] {
159+
continue
160+
}
161+
pi := &phaseInfo{
162+
record: rec,
163+
status: model.EntityStatus(rec.Status),
164+
}
165+
if rec.Metadata != "" {
166+
var meta map[string]any
167+
if err := json.Unmarshal([]byte(rec.Metadata), &meta); err == nil {
168+
if o, ok := meta["order"]; ok {
169+
if v, ok := o.(float64); ok {
170+
pi.order = v
171+
}
172+
}
173+
}
174+
}
175+
phases[rec.ID] = pi
176+
}
177+
178+
return phases
179+
}
180+
181+
func buildPredecessorMap(planPhaseIDs map[string]bool) map[string][]string {
182+
predecessors := make(map[string][]string)
183+
for phaseID := range planPhaseIDs {
184+
rels, relErr := queryIndex.GetRelationsByEntity(phaseID)
185+
if relErr != nil {
186+
continue
187+
}
188+
for _, rel := range rels {
189+
if rel.Type == string(model.RelationPrecedes) && rel.ToID == phaseID {
190+
predecessors[phaseID] = append(predecessors[phaseID], rel.FromID)
191+
}
192+
}
193+
}
194+
195+
return predecessors
196+
}
197+
198+
func selectNextPhase(phases map[string]*phaseInfo, predecessors map[string][]string) (string, *phaseInfo) {
199+
type candidate struct {
200+
id string
201+
order float64
202+
}
203+
var candidates []candidate
204+
205+
for phaseID, pi := range phases {
206+
if pi.status == model.EntityStatusResolved || pi.status == model.EntityStatusDeprecated {
207+
continue
208+
}
209+
210+
allResolved := true
211+
for _, predID := range predecessors[phaseID] {
212+
pred, ok := phases[predID]
213+
if !ok || pred.status != model.EntityStatusResolved {
214+
allResolved = false
215+
break
216+
}
217+
}
218+
219+
if allResolved {
220+
candidates = append(candidates, candidate{id: phaseID, order: pi.order})
221+
}
222+
}
223+
224+
if len(candidates) == 0 {
225+
return "", nil
226+
}
227+
228+
sort.Slice(candidates, func(i, j int) bool {
229+
return candidates[i].order < candidates[j].order
230+
})
231+
232+
return candidates[0].id, phases[candidates[0].id]
233+
}
234+
235+
func computePhaseScope(phaseID string) PhaseNextScope {
236+
rf := &indexRelationFetcher{idx: queryIndex}
237+
rels, _ := rf.GetByEntity(phaseID)
238+
239+
var coveredIDs []string
240+
deliveredSet := make(map[string]bool)
241+
for _, rel := range rels {
242+
if rel.FromID == phaseID && rel.Type == model.RelationCovers {
243+
coveredIDs = append(coveredIDs, rel.ToID)
244+
}
245+
if rel.FromID == phaseID && rel.Type == model.RelationDelivers {
246+
deliveredSet[rel.ToID] = true
247+
}
248+
}
249+
250+
var remaining []string
251+
for _, id := range coveredIDs {
252+
if !deliveredSet[id] {
253+
remaining = append(remaining, id)
254+
}
255+
}
256+
257+
return PhaseNextScope{
258+
Total: len(coveredIDs),
259+
Delivered: len(deliveredSet),
260+
Remaining: remaining,
261+
}
262+
}
263+
264+
func activatePhase(cmd *cobra.Command, phaseID string) error {
265+
ef, err := tomlStore.ReadEntity(phaseID, model.EntityTypePhase)
266+
if err != nil {
267+
return fmt.Errorf("read phase entity: %w", err)
268+
}
269+
ef.Status = model.EntityStatusActive
270+
ef.UpdatedAt = time.Now()
271+
if err := tomlStore.WriteEntity(ef); err != nil {
272+
return fmt.Errorf("write phase entity: %w", err)
273+
}
274+
if err := tomlStore.AppendHistory(phaseID, spectoml.HistoryEntry{
275+
Action: model.ActionUpdate,
276+
Reason: "Activated by phase next --activate",
277+
Timestamp: time.Now(),
278+
}); err != nil {
279+
fmt.Fprintf(cmd.ErrOrStderr(), "warning: failed to write history for %s: %v\n", phaseID, err)
280+
}
281+
282+
return nil
283+
}
284+
285+
func extractGoal(metadata string) string {
286+
if metadata == "" {
287+
return ""
288+
}
289+
var meta map[string]any
290+
if err := json.Unmarshal([]byte(metadata), &meta); err != nil {
291+
return ""
292+
}
293+
if g, ok := meta["goal"].(string); ok {
294+
return g
295+
}
296+
297+
return ""
298+
}
299+
300+
func phaseEntityScope(phaseID string, rf *indexRelationFetcher) (map[string]bool, error) {
301+
rels, err := rf.GetByEntity(phaseID)
302+
if err != nil {
303+
return nil, err
304+
}
305+
scope := make(map[string]bool)
306+
for _, r := range rels {
307+
if r.FromID == phaseID && (r.Type == model.RelationCovers || r.Type == model.RelationDelivers) {
308+
scope[r.ToID] = true
309+
}
310+
}
311+
312+
return scope, nil
313+
}
314+
315+
func init() {
316+
phaseNextCmd.Flags().Bool("activate", false, "automatically transition the phase from draft to active")
317+
phaseCmd.AddCommand(phaseNextCmd)
318+
}

0 commit comments

Comments
 (0)