From 469b850504c4c0e57415fa57007a3800fd613922 Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Fri, 28 Aug 2026 07:57:44 -0400 Subject: [PATCH 1/3] feat(graph): add guarded dependency operations Add snapshot-local resolution, guarded add/remove/migrate planning, legacy projection, blocker and downstream queries, and fail-closed eligibility selection. Preserve graph ownership across generic persistence paths and cover durable-prefix recovery, empty legacy fields, duplicate IDs, and deep graph behavior. --- internal/core/dependency_graph.go | 221 ++++++--- internal/core/dependency_graph_mutation.go | 27 +- internal/core/dependency_graph_test.go | 213 ++++++++- internal/core/dependency_operations.go | 437 ++++++++++++++++++ internal/core/dependency_operations_test.go | 243 ++++++++++ internal/core/listtasks_test.go | 26 ++ internal/core/service.go | 31 +- internal/core/service_task.go | 16 +- internal/domain/task.go | 4 + internal/store/dependency_operations_test.go | 208 +++++++++ internal/store/dependency_persistence_test.go | 3 + internal/store/edit.go | 42 +- internal/store/fsstore.go | 9 + internal/store/graphmutation.go | 2 +- 14 files changed, 1394 insertions(+), 88 deletions(-) create mode 100644 internal/core/dependency_operations.go create mode 100644 internal/core/dependency_operations_test.go create mode 100644 internal/store/dependency_operations_test.go diff --git a/internal/core/dependency_graph.go b/internal/core/dependency_graph.go index 0f380dfc..475505d9 100644 --- a/internal/core/dependency_graph.go +++ b/internal/core/dependency_graph.go @@ -14,9 +14,9 @@ import ( // GraphHealth describes whether one immutable repository task snapshot is safe // for graph-sensitive decisions. Degraded is intentionally distinct from broken: -// every legacy reference resolves, but those constraints are not canonical edges -// yet. Both degraded and broken snapshots fail closed for ordinary mutations and -// dispatch-oriented selectors. +// every legacy reference resolves and can be projected for diagnostic reads, but +// those constraints are not canonical edges yet. Both degraded and broken +// snapshots fail closed for ordinary mutations and dispatch-oriented selectors. type GraphHealth string const ( @@ -140,6 +140,14 @@ type Blocker struct { Direct bool } +// DependentImpact is one task reachable downstream from a queried task. Path +// starts at the queried task and ends at TaskID; Direct is true for one edge. +type DependentImpact struct { + TaskID string + Path []string + Direct bool +} + type TaskGraphState struct { TaskID string Role LifecycleRole @@ -236,29 +244,35 @@ type soundResult struct { broken bool } +type taskReferenceCandidate struct { + id string + slug string +} + // TaskGraph is an immutable projection over one repository scan. Its internal // query caches are synchronized; callers always receive copies of slices/maps. type TaskGraph struct { - tasks map[string]domain.Task - ids []string - dependencies map[string][]string - outgoing map[string][]string - problems []GraphProblem - legacy []LegacyDependencyDiagnostic - health GraphHealth - hardBroken map[string]bool - unreadableIDs map[string]bool - cycleMembers map[string]bool - sound map[string]soundResult - states map[string]TaskGraphState - waves [][]string - wavesComplete bool - - mu sync.Mutex - causalCache map[string][]Blocker - frontierCache map[string][]Blocker - downstreamCache map[string][]string - soundVisits map[string]int + tasks map[string]domain.Task + ids []string + dependencies map[string][]string + outgoing map[string][]string + problems []GraphProblem + legacy []LegacyDependencyDiagnostic + health GraphHealth + hardBroken map[string]bool + unreadableIDs map[string]bool + referenceCandidates []taskReferenceCandidate + cycleMembers map[string]bool + sound map[string]soundResult + states map[string]TaskGraphState + waves [][]string + wavesComplete bool + + mu sync.Mutex + causalCache map[string][]Blocker + frontierCache map[string][]Blocker + impactCache map[string][]DependentImpact + soundVisits map[string]int } // NewTaskGraph builds the production strict snapshot with the owned analyzer. @@ -268,23 +282,24 @@ func NewTaskGraph(tasks []domain.Task, unreadable []domain.FileProblem) *TaskGra func newTaskGraph(tasks []domain.Task, unreadable []domain.FileProblem) *TaskGraph { g := &TaskGraph{ - tasks: make(map[string]domain.Task, len(tasks)), - dependencies: make(map[string][]string, len(tasks)), - outgoing: make(map[string][]string, len(tasks)), - hardBroken: make(map[string]bool), - unreadableIDs: make(map[string]bool), - cycleMembers: make(map[string]bool), - sound: make(map[string]soundResult, len(tasks)), - states: make(map[string]TaskGraphState, len(tasks)), - causalCache: make(map[string][]Blocker), - frontierCache: make(map[string][]Blocker), - downstreamCache: make(map[string][]string), - soundVisits: make(map[string]int, len(tasks)), + tasks: make(map[string]domain.Task, len(tasks)), + dependencies: make(map[string][]string, len(tasks)), + outgoing: make(map[string][]string, len(tasks)), + hardBroken: make(map[string]bool), + unreadableIDs: make(map[string]bool), + cycleMembers: make(map[string]bool), + sound: make(map[string]soundResult, len(tasks)), + states: make(map[string]TaskGraphState, len(tasks)), + causalCache: make(map[string][]Blocker), + frontierCache: make(map[string][]Blocker), + impactCache: make(map[string][]DependentImpact), + soundVisits: make(map[string]int, len(tasks)), } for _, problem := range unreadable { - taskID := taskIDFromPath(problem.Path) + taskID, taskSlug := taskIdentityFromPath(problem.Path) if taskID != "" { g.unreadableIDs[taskID] = true + g.referenceCandidates = append(g.referenceCandidates, taskReferenceCandidate{id: taskID, slug: taskSlug}) g.hardBroken[taskID] = true } g.problems = append(g.problems, GraphProblem{ @@ -314,6 +329,9 @@ func newTaskGraph(tasks []domain.Task, unreadable []domain.FileProblem) *TaskGra } for _, task := range ordered { taskID := canonicalTaskID(task) + if taskID != "" { + g.referenceCandidates = append(g.referenceCandidates, taskReferenceCandidate{id: taskID, slug: task.Slug}) + } if strings.TrimSpace(task.ID) == "" { g.addProblem(GraphProblem{Code: ProblemMissingTaskID, TaskID: taskID, Field: "id", Path: task.Path, Message: "missing stable task id in frontmatter"}) @@ -396,12 +414,24 @@ func newTaskGraph(tasks []domain.Task, unreadable []domain.FileProblem) *TaskGra } } } + legacyDiagnostics, legacyEdges := g.resolveLegacyDiagnostics(ordered) + g.legacy = legacyDiagnostics + // Resolved legacy edges are semantically real constraints. Project them into + // explanatory reads and derived state as well as structural analysis so a + // degraded snapshot never reports a false all-clear before migration. The + // persisted/canonical source remains Task.DependsOn; unioning here is read-only. + for _, edge := range legacyEdges { + if taskExists(g.tasks, edge.From) && taskExists(g.tasks, edge.To) { + g.dependencies[edge.To] = append(g.dependencies[edge.To], edge.From) + g.outgoing[edge.From] = append(g.outgoing[edge.From], edge.To) + } + } + for taskID := range g.dependencies { + g.dependencies[taskID] = sortedUnique(g.dependencies[taskID]) + } for taskID := range g.outgoing { g.outgoing[taskID] = sortedUnique(g.outgoing[taskID]) } - - legacyDiagnostics, legacyEdges := g.resolveLegacyDiagnostics(ordered) - g.legacy = legacyDiagnostics projectedEdges := append(append([]DependencyEdge(nil), canonicalEdges...), legacyEdges...) structure := analyzeDAG(dagInput{Nodes: append([]string(nil), g.ids...), Edges: projectedEdges}) g.waves = cloneWaves(structure.TopologicalWaves) @@ -466,16 +496,17 @@ func canonicalTaskID(task domain.Task) string { return task.ID } -func taskIDFromPath(path string) string { +func taskIdentityFromPath(path string) (string, string) { base := filepath.Base(path) - if len(base) <= id.Length || base[id.Length] != '-' { - return "" + stem := strings.TrimSuffix(base, ".md") + if len(stem) < id.Length+2 || stem[id.Length] != '-' { + return "", "" } - candidate := base[:id.Length] + candidate := stem[:id.Length] if !id.Valid(candidate) { - return "" + return "", "" } - return candidate + return candidate, stem[id.Length+1:] } func cloneTask(task domain.Task) domain.Task { @@ -484,6 +515,7 @@ func cloneTask(task domain.Task) domain.Task { task.LegacyBlockedBy = append([]string(nil), task.LegacyBlockedBy...) task.LegacyDependencies = append([]string(nil), task.LegacyDependencies...) task.LegacyBlocks = append([]string(nil), task.LegacyBlocks...) + task.LegacyDependencyFields = append([]string(nil), task.LegacyDependencyFields...) return task } @@ -537,7 +569,7 @@ func (g *TaskGraph) resolveLegacyDiagnostics(records []domain.Task) ([]LegacyDep } for _, field := range fields { values := sortedUnique(field.values(task)) - if len(values) == 0 { + if len(values) == 0 && !slices.Contains(task.LegacyDependencyFields, field.name) { continue } diagnostic := LegacyDependencyDiagnostic{ @@ -733,6 +765,55 @@ func (g *TaskGraph) Task(taskID string) (domain.Task, bool) { return task, ok } +// ResolveTaskID applies the ordinary task-reference tiers to this immutable +// snapshot and returns the canonical stable ID. Keeping resolution on TaskGraph +// lets guarded planners resolve user input without Store re-entry or a pre-lock +// TOCTOU choice. Exact unreadable IDs remain addressable for diagnostic queries. +func (g *TaskGraph) ResolveTaskID(ref string) (string, error) { + if ref == "" || strings.ContainsAny(ref, `/\`) || strings.Contains(ref, "..") { + return "", fmt.Errorf("%w: task name %q must be a plain name (no path separators)", domain.ErrValidation, ref) + } + candidates := append([]taskReferenceCandidate(nil), g.referenceCandidates...) + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].id != candidates[j].id { + return candidates[i].id < candidates[j].id + } + return candidates[i].slug < candidates[j].slug + }) + + query := strings.ToLower(ref) + tiers := []func(string) bool{ + func(key string) bool { return key == ref || strings.ToLower(key) == query }, + func(key string) bool { return strings.HasPrefix(strings.ToLower(key), query) }, + func(key string) bool { return strings.Contains(strings.ToLower(key), query) }, + } + for _, matches := range tiers { + hits := make([]taskReferenceCandidate, 0) + for _, item := range candidates { + if matches(item.id) || (item.slug != "" && matches(item.slug)) { + hits = append(hits, item) + } + } + switch len(hits) { + case 0: + continue + case 1: + return hits[0].id, nil + default: + details := make([]string, len(hits)) + for i, hit := range hits { + if hit.slug == "" { + details[i] = hit.id + } else { + details[i] = fmt.Sprintf("%s (%s)", hit.slug, hit.id) + } + } + return "", fmt.Errorf("%q matches %d tasks: %s: %w", ref, len(hits), strings.Join(details, ", "), domain.ErrAmbiguous) + } + } + return "", fmt.Errorf("task %q: %w", ref, domain.ErrNotFound) +} + // SameSourceSnapshot reports whether two graphs came from the same exact task // files. It exposes no version token: the store can use it as a whole-repository // CAS after planning, while callbacks receive only Task() projections with the @@ -950,12 +1031,25 @@ func (g *TaskGraph) ExplainGate(taskID string) GateExplanation { // Downstream returns all transitive dependents in stable ID order, memoized per task. func (g *TaskGraph) Downstream(taskID string) []string { + impacts := g.DownstreamImpact(taskID) + result := make([]string, len(impacts)) + for i, impact := range impacts { + result[i] = impact.TaskID + } + return result +} + +// DownstreamImpact returns every transitive dependent with one deterministic +// shortest path. Stable outgoing adjacency plus BFS fixes tie-breaking; results +// are emitted in stable task-ID order. +func (g *TaskGraph) DownstreamImpact(taskID string) []DependentImpact { g.mu.Lock() defer g.mu.Unlock() - if cached, ok := g.downstreamCache[taskID]; ok { - return append([]string(nil), cached...) + if cached, ok := g.impactCache[taskID]; ok { + return cloneDependentImpacts(cached) } - seen := make(map[string]bool) + seen := map[string]bool{taskID: true} + parent := make(map[string]string) queue := []string{taskID} for len(queue) > 0 { current := queue[0] @@ -965,17 +1059,25 @@ func (g *TaskGraph) Downstream(taskID string) []string { continue } seen[dependent] = true + parent[dependent] = current queue = append(queue, dependent) } } - delete(seen, taskID) - result := make([]string, 0, len(seen)) + ids := make([]string, 0, len(seen)) for dependent := range seen { - result = append(result, dependent) + if dependent == taskID { + continue + } + ids = append(ids, dependent) + } + sort.Strings(ids) + result := make([]DependentImpact, 0, len(ids)) + for _, dependent := range ids { + path := blockerPath(taskID, dependent, parent) + result = append(result, DependentImpact{TaskID: dependent, Path: path, Direct: len(path) == 2}) } - sort.Strings(result) - g.downstreamCache[taskID] = result - return append([]string(nil), result...) + g.impactCache[taskID] = result + return cloneDependentImpacts(result) } func (g *TaskGraph) TopologicalWaves() ([][]string, bool) { @@ -991,6 +1093,15 @@ func cloneBlockers(values []Blocker) []Blocker { return out } +func cloneDependentImpacts(values []DependentImpact) []DependentImpact { + out := make([]DependentImpact, len(values)) + copy(out, values) + for i := range out { + out[i].Path = append([]string(nil), out[i].Path...) + } + return out +} + func cloneWaves(values [][]string) [][]string { out := make([][]string, len(values)) for i := range values { diff --git a/internal/core/dependency_graph_mutation.go b/internal/core/dependency_graph_mutation.go index e0756710..9847d226 100644 --- a/internal/core/dependency_graph_mutation.go +++ b/internal/core/dependency_graph_mutation.go @@ -16,8 +16,8 @@ func ValidateTaskGraphMutationSource(graph *TaskGraph) error { return fmt.Errorf("%w: authoritative task graph is required", domain.ErrValidation) } if graph.Health() == GraphBroken { - return fmt.Errorf("%w: repository task graph is broken; repair it before mutation: %s", - domain.ErrValidation, graphMutationHealthDetail(graph)) + return fmt.Errorf("%w: repository task graph is broken: %s", + domain.ErrValidation, taskGraphHealthDetail(graph)) } return nil } @@ -78,20 +78,21 @@ func ValidateTaskGraphMutationPlan(graph *TaskGraph, plan TaskGraphMutationPlan) task.LegacyBlockedBy = nil task.LegacyDependencies = nil task.LegacyBlocks = nil + task.LegacyDependencyFields = nil } prospective[write.TaskID] = task prefixGraph := taskGraphFromMap(taskIDs, prospective) if prefixGraph.Health() == GraphBroken { return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned write prefix ending at task %s would leave a broken graph: %s", - domain.ErrValidation, write.TaskID, graphMutationHealthDetail(prefixGraph)) + domain.ErrValidation, write.TaskID, taskGraphHealthDetail(prefixGraph)) } } finalGraph := taskGraphFromMap(taskIDs, prospective) if !finalGraph.MutationReady() { return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned dependency state is %s; mutation requires a healthy final graph: %s", - domain.ErrValidation, finalGraph.Health(), graphMutationHealthDetail(finalGraph)) + domain.ErrValidation, finalGraph.Health(), taskGraphHealthDetail(finalGraph)) } return normalized, nil } @@ -104,16 +105,24 @@ func taskGraphFromMap(taskIDs []string, tasksByID map[string]domain.Task) *TaskG return NewTaskGraph(tasks, nil) } -func graphMutationHealthDetail(graph *TaskGraph) string { +func taskGraphHealthDetail(graph *TaskGraph) string { if problems := graph.Problems(); len(problems) > 0 { - detail := problems[0].Message + first := problems[0] + location := "" + if first.Path != "" { + location = fmt.Sprintf(" in %s", first.Path) + if first.Field != "" { + location += fmt.Sprintf(" (field %s)", first.Field) + } + } + detail := first.Message + location if len(problems) > 1 { - detail += fmt.Sprintf(" (%d additional problem(s); run lint for the full sweep)", len(problems)-1) + detail += fmt.Sprintf(" (%d additional problem(s))", len(problems)-1) } - return detail + return detail + "; repair the graph-owned frontmatter directly, then run `tskflwctl lint`" } if legacy := graph.LegacyDiagnostics(); len(legacy) > 0 { - return fmt.Sprintf("%d legacy dependency field occurrence(s) remain; run the guarded migration", len(legacy)) + return fmt.Sprintf("%d legacy dependency field occurrence(s) remain; run `tskflwctl task depend migrate`", len(legacy)) } return "graph health is not mutation-ready" } diff --git a/internal/core/dependency_graph_test.go b/internal/core/dependency_graph_test.go index 64d493b6..2d85cb13 100644 --- a/internal/core/dependency_graph_test.go +++ b/internal/core/dependency_graph_test.go @@ -164,11 +164,37 @@ func TestTaskGraphLegacyResolutionHealthAndDirection(t *testing.T) { } } } - // Degraded means canonical queries remain explanatory, but dispatch does not - // claim eligible work while the legacy constraint is still hidden from the DAG. + // Resolved legacy constraints are projected into explanatory reads and derived + // gates even though dispatch still fails closed until migration makes them + // canonical. if state := graph.State(dependent.ID); state.Gate != GateClear || state.Eligible { t.Fatalf("degraded candidate state = %+v", state) } + prerequisite.Status = domain.StatusReadyToStart + blocked := NewTaskGraph([]domain.Task{dependent, prerequisite}, nil) + if state := blocked.State(dependent.ID); state.Gate != GateBlocked || state.Eligible { + t.Fatalf("degraded blocked state = %+v", state) + } + if blockers := blocked.CausalBlockers(dependent.ID); len(blockers) != 1 || blockers[0].TaskID != prerequisite.ID { + t.Fatalf("legacy blocker projection = %+v", blockers) + } + if impacts := blocked.DownstreamImpact(prerequisite.ID); len(impacts) != 1 || impacts[0].TaskID != dependent.ID { + t.Fatalf("legacy downstream projection = %+v", impacts) + } +} + +func TestTaskGraphDiagnosesPresentEmptyLegacyFields(t *testing.T) { + task := graphRecord("empty-legacy", domain.StatusReadyToStart) + task.LegacyDependencyFields = []string{"blocked_by", "dependencies", "blocks"} + graph := NewTaskGraph([]domain.Task{task}, nil) + if graph.Health() != GraphDegraded || len(graph.LegacyDiagnostics()) != 3 { + t.Fatalf("empty legacy health=%s diagnostics=%+v", graph.Health(), graph.LegacyDiagnostics()) + } + for _, diagnostic := range graph.LegacyDiagnostics() { + if len(diagnostic.References) != 0 { + t.Fatalf("empty %s references = %+v", diagnostic.Field, diagnostic.References) + } + } } func TestTaskGraphLegacyMissingAndAmbiguousAreBroken(t *testing.T) { @@ -222,6 +248,91 @@ func TestTaskGraphTopologicalWavesAndDownstream(t *testing.T) { } } +func TestTaskGraphResolveTaskIDMatchesRepositoryReferenceTiers(t *testing.T) { + polish := graphRecord("polish", domain.StatusReadyToStart) + batch := graphRecord("polish-batch", domain.StatusReadyToStart) + backoff := graphRecord("add-retry-backoff", domain.StatusReadyToStart) + jitter := graphRecord("add-retry-jitter", domain.StatusReadyToStart) + unreadableID := testutil.TaskID("unreadable") + graph := NewTaskGraph( + []domain.Task{jitter, batch, backoff, polish}, + []domain.FileProblem{{Path: "tasks/" + unreadableID + "-unreadable.md", Message: "bad YAML"}}, + ) + + tests := []struct { + query string + want string + }{ + {query: "polish", want: polish.ID}, // exact beats a longer prefix + {query: "POLISH-B", want: batch.ID}, // unique prefix, case-insensitive + {query: "JITTER", want: jitter.ID}, // unique substring, case-insensitive + {query: backoff.ID[:7], want: backoff.ID}, // stable-ID prefix + {query: unreadableID, want: unreadableID}, // exact diagnostic addressability + {query: "UNREAD", want: unreadableID}, // filename slug parity despite unreadable YAML + } + for _, tc := range tests { + got, err := graph.ResolveTaskID(tc.query) + if err != nil || got != tc.want { + t.Errorf("ResolveTaskID(%q) = %q, %v; want %q", tc.query, got, err, tc.want) + } + } + + if _, err := graph.ResolveTaskID("add-retry"); !errors.Is(err, domain.ErrAmbiguous) || + !strings.Contains(err.Error(), backoff.ID) || !strings.Contains(err.Error(), jitter.ID) { + t.Fatalf("ambiguous resolution should be classified and list canonical IDs: %v", err) + } + if _, err := graph.ResolveTaskID("does-not-exist"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing resolution = %v, want ErrNotFound", err) + } + for _, query := range []string{"", "../escape", `a\b`, "a/b", ".."} { + if _, err := graph.ResolveTaskID(query); !errors.Is(err, domain.ErrValidation) { + t.Errorf("unsafe query %q = %v, want ErrValidation", query, err) + } + } + + duplicateA := graphRecord("duplicate-one", domain.StatusReadyToStart) + duplicateB := graphRecord("duplicate-two", domain.StatusReadyToStart) + duplicateB.ID, duplicateB.FilenameID = duplicateA.ID, duplicateA.ID + duplicateGraph := NewTaskGraph([]domain.Task{duplicateA, duplicateB}, nil) + if _, err := duplicateGraph.ResolveTaskID(duplicateA.ID); !errors.Is(err, domain.ErrAmbiguous) || + !strings.Contains(err.Error(), duplicateA.Slug) || !strings.Contains(err.Error(), duplicateB.Slug) { + t.Fatalf("duplicate-id resolution = %v, want both source candidates and ErrAmbiguous", err) + } +} + +func TestTaskGraphDownstreamImpactUsesDeterministicShortestPaths(t *testing.T) { + root := graphRecord("impact-root", domain.StatusCompleted) + left := graphRecord("impact-left", domain.StatusCompleted, root.ID) + right := graphRecord("impact-right", domain.StatusCompleted, root.ID) + join := graphRecord("impact-join", domain.StatusReadyToStart, right.ID, left.ID) + graph := NewTaskGraph([]domain.Task{join, right, left, root}, nil) + + got := graph.DownstreamImpact(root.ID) + byID := make(map[string]DependentImpact, len(got)) + for _, impact := range got { + byID[impact.TaskID] = impact + } + if !byID[left.ID].Direct || !byID[right.ID].Direct { + t.Fatalf("immediate downstream tasks were not marked direct: %+v", got) + } + first := left.ID + if right.ID < left.ID { + first = right.ID + } + wantPath := []string{root.ID, first, join.ID} + if impact := byID[join.ID]; impact.Direct || !reflect.DeepEqual(impact.Path, wantPath) { + t.Fatalf("join impact = %+v, want shortest path %v", impact, wantPath) + } + + // Cached paths remain immutable to callers. + byID[join.ID].Path[0] = "corrupt" + for _, impact := range graph.DownstreamImpact(root.ID) { + if impact.TaskID == join.ID && impact.Path[0] != root.ID { + t.Fatalf("downstream impact cache leaked mutable path: %+v", impact) + } + } +} + func TestAnalyzeDAGDeepWideAndDisconnected(t *testing.T) { const depth = 2048 nodes := make([]string, 0, depth+129) @@ -248,6 +359,88 @@ func TestAnalyzeDAGDeepWideAndDisconnected(t *testing.T) { } } +func TestTaskGraphSupportedDeepChainEnvelope(t *testing.T) { + // 4,096 edges is deliberately far beyond a plausible markdown planning repo + // while still cheap enough for every CI run. Unlike TestAnalyzeDAGDeep..., this + // exercises the complete snapshot, recursive sound derivation, and explanatory + // path materialization rather than only the structural analyzer. + const edges = 4096 + tasks := make([]domain.Task, 0, edges+1) + for i := 0; i <= edges; i++ { + taskID := fmt.Sprintf("%012d", i) + status := domain.StatusCompleted + if i == 0 || i == edges { + status = domain.StatusReadyToStart + } + task := domain.Task{ + ID: taskID, FilenameID: taskID, Slug: fmt.Sprintf("deep-%04d", i), + Path: "tasks/" + taskID + "-deep.md", Status: status, + } + if i < edges { + task.DependsOn = []string{fmt.Sprintf("%012d", i+1)} + } + tasks = append(tasks, task) + } + graph := NewTaskGraph(tasks, nil) + if graph.Health() != GraphHealthy { + t.Fatalf("deep graph health = %s", graph.Health()) + } + state := graph.State("000000000000") + if state.Gate != GateBlocked || state.Eligible { + t.Fatalf("deep root state = %+v", state) + } + frontier := graph.BlockingFrontier("000000000000") + if len(frontier) != 1 || frontier[0].TaskID != fmt.Sprintf("%012d", edges) || len(frontier[0].Path) != edges+1 { + t.Fatalf("deep frontier count=%d blocker=%+v", len(frontier), frontier) + } +} + +func TestTaskGraphPathProjectionOutputEnvelope(t *testing.T) { + // Full explanatory paths amplify a linear chain quadratically. Keep that cost + // explicit at a large-but-CI-safe depth; the supported 4,096-edge structural and + // frontier envelope remains covered separately above. + const edges = 512 + tasks := make([]domain.Task, 0, edges+1) + for i := 0; i <= edges; i++ { + taskID := fmt.Sprintf("%012d", i) + status := domain.StatusCompleted + if i == edges { + status = domain.StatusReadyToStart + } + task := domain.Task{ID: taskID, FilenameID: taskID, Slug: fmt.Sprintf("path-%04d", i), Status: status} + if i < edges { + task.DependsOn = []string{fmt.Sprintf("%012d", i+1)} + } + tasks = append(tasks, task) + } + graph := NewTaskGraph(tasks, nil) + wantPathElements := edges * (edges + 3) / 2 // lengths 2..edges+1 + causal := graph.CausalBlockers("000000000000") + if len(causal) != edges || totalBlockerPathElements(causal) != wantPathElements { + t.Fatalf("causal count=%d path-elements=%d, want %d/%d", len(causal), totalBlockerPathElements(causal), edges, wantPathElements) + } + impacts := graph.DownstreamImpact(fmt.Sprintf("%012d", edges)) + if len(impacts) != edges || totalImpactPathElements(impacts) != wantPathElements { + t.Fatalf("impact count=%d path-elements=%d, want %d/%d", len(impacts), totalImpactPathElements(impacts), edges, wantPathElements) + } +} + +func totalBlockerPathElements(blockers []Blocker) int { + total := 0 + for _, blocker := range blockers { + total += len(blocker.Path) + } + return total +} + +func totalImpactPathElements(impacts []DependentImpact) int { + total := 0 + for _, impact := range impacts { + total += len(impact.Path) + } + return total +} + func TestTaskGraphCycleBlockerReason(t *testing.T) { a := graphRecord("cycle-a", domain.StatusCompleted) b := graphRecord("cycle-b", domain.StatusCompleted, a.ID) @@ -267,6 +460,11 @@ func TestTaskGraphCycleBlockerReason(t *testing.T) { t.Fatalf("cycle blocker = %+v", blocker) } } + for _, impact := range graph.DownstreamImpact(a.ID) { + if impact.TaskID == a.ID { + t.Fatalf("cyclic downstream query reported its source as its own impact: %+v", impact) + } + } } func TestTaskGraphSCCMarksEveryMemberAndEmitsRepresentativePath(t *testing.T) { @@ -511,6 +709,17 @@ func TestValidateTaskGraphMutationPlanPreservesSemanticWriteOrder(t *testing.T) } } +func TestValidateTaskGraphMutationSourceNamesManualRepairPath(t *testing.T) { + task := graphRecord("manual-repair", domain.StatusReadyToStart, "not-a-stable-id") + err := ValidateTaskGraphMutationSource(NewTaskGraph([]domain.Task{task}, nil)) + if !errors.Is(err, domain.ErrValidation) || !strings.Contains(err.Error(), task.Path) || + !strings.Contains(err.Error(), "field depends_on") || + !strings.Contains(err.Error(), "repair the graph-owned frontmatter directly") || + !strings.Contains(err.Error(), "tskflwctl lint") { + t.Fatalf("broken graph recovery guidance = %v", err) + } +} + func sortStrings(values []string) { slices.Sort(values) } diff --git a/internal/core/dependency_operations.go b/internal/core/dependency_operations.go new file mode 100644 index 00000000..66dee7d8 --- /dev/null +++ b/internal/core/dependency_operations.go @@ -0,0 +1,437 @@ +package core + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + + "github.com/andy-esch/taskflow/internal/domain" +) + +// DependencyOperation is the stable use-case vocabulary carried by mutation +// receipts. It deliberately names user intent rather than persistence mechanics. +type DependencyOperation string + +const ( + DependencyAdd DependencyOperation = "add" + DependencyRemove DependencyOperation = "remove" + DependencyMigrate DependencyOperation = "migrate" +) + +// DependencyEdgeOutcome reports one canonical edge intent. Outcome is the +// semantic result; DryRun on the containing receipt distinguishes preview from +// durable application. +type DependencyEdgeOutcome struct { + DependentID string + PrerequisiteID string + Action DependencyOperation + Outcome string // added | removed | skipped +} + +// LegacyFieldClear identifies one legacy field occurrence removed by migration. +type LegacyFieldClear struct { + TaskID string + Field string +} + +// DependencyMutationReceipt is the adapter-neutral mutation result. Planned and +// Applied refer to task-file replacements; Remaining is populated only on failure +// so an interrupted multi-file migration is explicitly resumable. +type DependencyMutationReceipt struct { + Operation DependencyOperation + Changed bool + DryRun bool + Edges []DependencyEdgeOutcome + ClearedLegacyFields []LegacyFieldClear + PlannedTaskIDs []string + AppliedTaskIDs []string + RemainingTaskIDs []string +} + +// DependencyMutationFailure preserves a typed receipt on error. Error includes +// the durable prefix for human adapters; machine adapters can errors.As and emit +// the complete structured receipt without parsing this text. +type DependencyMutationFailure struct { + Cause error + Receipt DependencyMutationReceipt +} + +func (e *DependencyMutationFailure) Error() string { + if e == nil || e.Cause == nil { + return "dependency mutation failed" + } + if len(e.Receipt.AppliedTaskIDs) == 0 { + return e.Cause.Error() + } + if len(e.Receipt.RemainingTaskIDs) == 0 { + return fmt.Sprintf("%v; all planned dependency task files were durably applied to %s; verify current graph state before deciding whether to retry", + e.Cause, strings.Join(e.Receipt.AppliedTaskIDs, ", ")) + } + return fmt.Sprintf("%v; durable dependency prefix applied to %s; retry the same command to converge remaining tasks %s", + e.Cause, strings.Join(e.Receipt.AppliedTaskIDs, ", "), strings.Join(e.Receipt.RemainingTaskIDs, ", ")) +} + +func (e *DependencyMutationFailure) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +type dependencyPlanDetails struct { + edges []DependencyEdgeOutcome + clears []LegacyFieldClear +} + +// AddTaskDependencies adds hard prerequisites to one dependent task through the +// repository-global graph guard. +func (s *Service) AddTaskDependencies(taskRef string, prerequisiteRefs []string, dryRun bool) (DependencyMutationReceipt, error) { + return s.mutateTaskDependencies(DependencyAdd, taskRef, prerequisiteRefs, dryRun) +} + +// RemoveTaskDependencies removes hard prerequisites from one dependent task +// through the repository-global graph guard. +func (s *Service) RemoveTaskDependencies(taskRef string, prerequisiteRefs []string, dryRun bool) (DependencyMutationReceipt, error) { + return s.mutateTaskDependencies(DependencyRemove, taskRef, prerequisiteRefs, dryRun) +} + +func (s *Service) mutateTaskDependencies(operation DependencyOperation, taskRef string, prerequisiteRefs []string, dryRun bool) (DependencyMutationReceipt, error) { + if operation != DependencyAdd && operation != DependencyRemove { + return DependencyMutationReceipt{}, fmt.Errorf("%w: unsupported dependency operation %q", domain.ErrValidation, operation) + } + if strings.TrimSpace(taskRef) == "" { + return DependencyMutationReceipt{}, fmt.Errorf("%w: dependent task is required", domain.ErrValidation) + } + if len(prerequisiteRefs) == 0 { + return DependencyMutationReceipt{}, fmt.Errorf("%w: at least one --on prerequisite is required", domain.ErrValidation) + } + return s.runDependencyMutation(operation, dryRun, func(graph *TaskGraph) (TaskGraphMutationPlan, dependencyPlanDetails, error) { + return planDependencyEdges(graph, operation, taskRef, prerequisiteRefs) + }) +} + +// MigrateTaskDependencies converts every safe legacy dependency occurrence in +// the repository. Broken legacy references are rejected by the guarded source +// validator before this planner runs. +func (s *Service) MigrateTaskDependencies(dryRun bool) (DependencyMutationReceipt, error) { + return s.runDependencyMutation(DependencyMigrate, dryRun, planLegacyDependencyMigration) +} + +type dependencyPlanner func(*TaskGraph) (TaskGraphMutationPlan, dependencyPlanDetails, error) + +func (s *Service) runDependencyMutation(operation DependencyOperation, dryRun bool, planner dependencyPlanner) (DependencyMutationReceipt, error) { + if s.graphMutations == nil { + return DependencyMutationReceipt{}, fmt.Errorf("dependency mutations are unavailable from this store") + } + now := s.now() + var receipt DependencyMutationReceipt + for attempt := 0; ; attempt++ { + details := dependencyPlanDetails{} + result, err := s.graphMutations.MutateTaskGraph(now, dryRun, func(graph *TaskGraph) (TaskGraphMutationPlan, error) { + plan, plannedDetails, planErr := planner(graph) + details = plannedDetails + return plan, planErr + }) + receipt = dependencyReceipt(operation, result, details, err) + // A graph conflict is safe to retry only before any replacement landed. + // Once a durable prefix exists, surface it; silently replaying would erase + // the recovery event the caller needs to understand. + if dryRun || !errors.Is(err, domain.ErrConflict) || len(result.AppliedTaskIDs) > 0 || attempt >= s.maxRetries { + if err != nil { + return receipt, &DependencyMutationFailure{Cause: err, Receipt: receipt} + } + return receipt, nil + } + s.retrySleep(attempt + 1) + } +} + +func dependencyReceipt(operation DependencyOperation, result TaskGraphMutationResult, details dependencyPlanDetails, mutationErr error) DependencyMutationReceipt { + planned := make([]string, len(result.Plan.TaskWrites)) + for i, write := range result.Plan.TaskWrites { + planned[i] = write.TaskID + } + receipt := DependencyMutationReceipt{ + Operation: operation, Changed: len(planned) > 0, DryRun: result.DryRun, + Edges: append([]DependencyEdgeOutcome(nil), details.edges...), + ClearedLegacyFields: append([]LegacyFieldClear(nil), details.clears...), + PlannedTaskIDs: planned, AppliedTaskIDs: append([]string(nil), result.AppliedTaskIDs...), + } + if mutationErr != nil { + applied := make(map[string]bool, len(result.AppliedTaskIDs)) + for _, taskID := range result.AppliedTaskIDs { + applied[taskID] = true + } + for _, taskID := range planned { + if !applied[taskID] { + receipt.RemainingTaskIDs = append(receipt.RemainingTaskIDs, taskID) + } + } + } + return receipt +} + +func planDependencyEdges(graph *TaskGraph, operation DependencyOperation, taskRef string, prerequisiteRefs []string) (TaskGraphMutationPlan, dependencyPlanDetails, error) { + dependentID, err := graph.ResolveTaskID(taskRef) + if err != nil { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, err + } + dependent, ok := graph.Task(dependentID) + if !ok { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("task %q resolved to unreadable task %s: %w", taskRef, dependentID, domain.ErrValidation) + } + + prerequisites := make([]string, 0, len(prerequisiteRefs)) + seen := make(map[string]string, len(prerequisiteRefs)) + for _, ref := range prerequisiteRefs { + prerequisiteID, resolveErr := graph.ResolveTaskID(ref) + if resolveErr != nil { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, resolveErr + } + if previous, duplicate := seen[prerequisiteID]; duplicate { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("%w: prerequisite references %q and %q both resolve to task %s", domain.ErrValidation, previous, ref, prerequisiteID) + } + seen[prerequisiteID] = ref + prerequisites = append(prerequisites, prerequisiteID) + } + sort.Strings(prerequisites) + + dependencies := append([]string(nil), dependent.DependsOn...) + details := dependencyPlanDetails{edges: make([]DependencyEdgeOutcome, 0, len(prerequisites))} + changed := false + for _, prerequisiteID := range prerequisites { + if prerequisiteID == dependentID { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("%w: task %s cannot depend on itself", domain.ErrValidation, dependentID) + } + present := slices.Contains(dependencies, prerequisiteID) + outcome := DependencyEdgeOutcome{DependentID: dependentID, PrerequisiteID: prerequisiteID, Action: operation, Outcome: "skipped"} + switch operation { + case DependencyAdd: + if !present { + dependencies = append(dependencies, prerequisiteID) + outcome.Outcome = "added" + changed = true + } + case DependencyRemove: + if present { + dependencies = slices.DeleteFunc(dependencies, func(id string) bool { return id == prerequisiteID }) + outcome.Outcome = "removed" + changed = true + } + } + details.edges = append(details.edges, outcome) + } + if !changed { + return TaskGraphMutationPlan{}, details, nil + } + return TaskGraphMutationPlan{TaskWrites: []TaskDependencyWrite{{TaskID: dependentID, DependsOn: dependencies}}}, details, nil +} + +func planLegacyDependencyMigration(graph *TaskGraph) (TaskGraphMutationPlan, dependencyPlanDetails, error) { + diagnostics := graph.LegacyDiagnostics() + if len(diagnostics) == 0 { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, nil + } + details := dependencyPlanDetails{} + clearOwners := make(map[string]bool) + desired := make(map[string][]string) + edges := make(map[DependencyEdge]DependencyEdgeOutcome) + clears := make(map[LegacyFieldClear]bool) + + for _, diagnostic := range diagnostics { + clearOwners[diagnostic.TaskID] = true + clears[LegacyFieldClear{TaskID: diagnostic.TaskID, Field: diagnostic.Field}] = true + for _, ref := range diagnostic.References { + if ref.Resolution != LegacyResolved { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("%w: legacy %s reference %q on task %s is %s", domain.ErrValidation, diagnostic.Field, ref.Value, diagnostic.TaskID, ref.Resolution) + } + dependent, ok := graph.Task(ref.Edge.To) + if !ok { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("%w: resolved legacy edge targets unreadable task %s", domain.ErrValidation, ref.Edge.To) + } + dependencies, initialized := desired[ref.Edge.To] + if !initialized { + dependencies = append([]string(nil), dependent.DependsOn...) + } + outcome := DependencyEdgeOutcome{DependentID: ref.Edge.To, PrerequisiteID: ref.Edge.From, Action: DependencyAdd, Outcome: "skipped"} + if !slices.Contains(dependencies, ref.Edge.From) { + dependencies = append(dependencies, ref.Edge.From) + outcome.Outcome = "added" + } + desired[ref.Edge.To] = dependencies + if previous, exists := edges[ref.Edge]; !exists || previous.Outcome == "skipped" { + edges[ref.Edge] = outcome + } + } + } + + for _, outcome := range edges { + details.edges = append(details.edges, outcome) + } + sort.Slice(details.edges, func(i, j int) bool { + if details.edges[i].DependentID != details.edges[j].DependentID { + return details.edges[i].DependentID < details.edges[j].DependentID + } + return details.edges[i].PrerequisiteID < details.edges[j].PrerequisiteID + }) + for clear := range clears { + details.clears = append(details.clears, clear) + } + sort.Slice(details.clears, func(i, j int) bool { + if details.clears[i].TaskID != details.clears[j].TaskID { + return details.clears[i].TaskID < details.clears[j].TaskID + } + return details.clears[i].Field < details.clears[j].Field + }) + + affected := make(map[string]bool) + for taskID := range clearOwners { + affected[taskID] = true + } + for taskID, dependencies := range desired { + task, _ := graph.Task(taskID) + sort.Strings(dependencies) + desired[taskID] = dependencies + if !slices.Equal(task.DependsOn, dependencies) { + affected[taskID] = true + } + } + + // Write dependents before prerequisites. This makes a legacy `blocks` edge + // canonical on its dependent before the prerequisite-owner clears the legacy + // declaration; duplicate projected edges are harmless, disappearing edges are + // avoided, and every prefix remains semantically conservative. + waves, _ := graph.TopologicalWaves() + waveByTask := make(map[string]int, len(graph.TaskIDs())) + for waveIndex, wave := range waves { + for _, taskID := range wave { + waveByTask[taskID] = waveIndex + } + } + ordered := make([]string, 0, len(affected)) + for taskID := range affected { + if _, ok := waveByTask[taskID]; !ok { + return TaskGraphMutationPlan{}, dependencyPlanDetails{}, fmt.Errorf("%w: legacy migration cannot order task %s in the projected graph", domain.ErrValidation, taskID) + } + ordered = append(ordered, taskID) + } + sort.Slice(ordered, func(i, j int) bool { + leftWave, rightWave := waveByTask[ordered[i]], waveByTask[ordered[j]] + if leftWave != rightWave { + return leftWave > rightWave + } + return ordered[i] < ordered[j] + }) + + plan := TaskGraphMutationPlan{TaskWrites: make([]TaskDependencyWrite, 0, len(ordered))} + for _, taskID := range ordered { + task, _ := graph.Task(taskID) + dependencies, ok := desired[taskID] + if !ok { + dependencies = append([]string(nil), task.DependsOn...) + } + plan.TaskWrites = append(plan.TaskWrites, TaskDependencyWrite{ + TaskID: taskID, DependsOn: dependencies, ClearLegacy: clearOwners[taskID], + }) + } + return plan, details, nil +} + +// TaskBlockerDetail enriches one graph blocker with any readable task metadata +// and its current derived state. Missing/unreadable blockers keep zero Task data +// while retaining their stable ID, reason, and path. +type TaskBlockerDetail struct { + Blocker Blocker + Task domain.Task + State TaskGraphState +} + +type TaskBlockersResult struct { + TaskID string + Task domain.Task + State TaskGraphState + Projection string // frontier | causal + Health GraphHealth + Problems []GraphProblem + Legacy []LegacyDependencyDiagnostic + Blockers []TaskBlockerDetail +} + +// TaskBlockers returns either the action frontier (default) or the full causal +// closure. It is diagnostic and therefore reports degraded/broken snapshots +// rather than failing the read. +func (s *Service) TaskBlockers(ref string, causal bool) (TaskBlockersResult, error) { + graph, taskID, task, err := s.resolveTaskGraphQuery(ref) + if err != nil { + return TaskBlockersResult{}, err + } + projection := "frontier" + blockers := graph.BlockingFrontier(taskID) + if causal { + projection = "causal" + blockers = graph.CausalBlockers(taskID) + } + result := TaskBlockersResult{ + TaskID: taskID, Task: task, State: graph.State(taskID), Projection: projection, Health: graph.Health(), + Problems: graph.Problems(), Legacy: graph.LegacyDiagnostics(), + Blockers: make([]TaskBlockerDetail, 0, len(blockers)), + } + for _, blocker := range blockers { + blockerTask, _ := graph.Task(blocker.TaskID) + result.Blockers = append(result.Blockers, TaskBlockerDetail{ + Blocker: blocker, Task: blockerTask, State: graph.State(blocker.TaskID), + }) + } + return result, nil +} + +type TaskDependentDetail struct { + Impact DependentImpact + Task domain.Task + State TaskGraphState +} + +type TaskUnblocksResult struct { + TaskID string + Task domain.Task + State TaskGraphState + Health GraphHealth + Problems []GraphProblem + Legacy []LegacyDependencyDiagnostic + Unblocks []TaskDependentDetail +} + +// TaskUnblocks reports transitive downstream impact. It does not simulate a +// completion or claim every dependent would become eligible. +func (s *Service) TaskUnblocks(ref string) (TaskUnblocksResult, error) { + graph, taskID, task, err := s.resolveTaskGraphQuery(ref) + if err != nil { + return TaskUnblocksResult{}, err + } + result := TaskUnblocksResult{ + TaskID: taskID, Task: task, State: graph.State(taskID), Health: graph.Health(), Problems: graph.Problems(), + Legacy: graph.LegacyDiagnostics(), + } + for _, impact := range graph.DownstreamImpact(taskID) { + dependent, _ := graph.Task(impact.TaskID) + result.Unblocks = append(result.Unblocks, TaskDependentDetail{ + Impact: impact, Task: dependent, State: graph.State(impact.TaskID), + }) + } + return result, nil +} + +func (s *Service) resolveTaskGraphQuery(ref string) (*TaskGraph, string, domain.Task, error) { + graph, err := LoadTaskGraph(s.store) + if err != nil { + return nil, "", domain.Task{}, err + } + taskID, err := graph.ResolveTaskID(ref) + if err != nil { + return nil, "", domain.Task{}, err + } + task, _ := graph.Task(taskID) + return graph, taskID, task, nil +} diff --git a/internal/core/dependency_operations_test.go b/internal/core/dependency_operations_test.go new file mode 100644 index 00000000..aaf29517 --- /dev/null +++ b/internal/core/dependency_operations_test.go @@ -0,0 +1,243 @@ +package core + +import ( + "errors" + "reflect" + "slices" + "testing" + "time" + + "github.com/andy-esch/taskflow/internal/domain" +) + +type graphMutationFailure struct { + err error + after int +} + +// graphOperationStore is a small semantic implementation of the mutation port. +// The filesystem adapter has its own locking/CAS tests; these tests isolate the +// service planners, receipts, retry boundary, and query behavior. +type graphOperationStore struct { + fakeStore + calls int + failures []graphMutationFailure +} + +func (s *graphOperationStore) MutateTaskGraph(_ time.Time, dryRun bool, planner TaskGraphPlanner) (TaskGraphMutationResult, error) { + s.calls++ + result := TaskGraphMutationResult{DryRun: dryRun} + graph := NewTaskGraph(s.tasks, s.problems) + if err := ValidateTaskGraphMutationSource(graph); err != nil { + return result, err + } + plan, err := planner(graph) + if err != nil { + return result, err + } + result.Plan, err = ValidateTaskGraphMutationPlan(graph, plan) + if err != nil || dryRun { + return result, err + } + var failure graphMutationFailure + if len(s.failures) > 0 { + failure, s.failures = s.failures[0], s.failures[1:] + if failure.err != nil && failure.after == 0 { + return result, failure.err + } + } + for _, write := range result.Plan.TaskWrites { + for i := range s.tasks { + if s.tasks[i].ID != write.TaskID { + continue + } + s.tasks[i].DependsOn = append([]string(nil), write.DependsOn...) + if write.ClearLegacy { + s.tasks[i].LegacyBlockedBy = nil + s.tasks[i].LegacyDependencies = nil + s.tasks[i].LegacyBlocks = nil + s.tasks[i].LegacyDependencyFields = nil + } + break + } + result.AppliedTaskIDs = append(result.AppliedTaskIDs, write.TaskID) + if failure.err != nil && len(result.AppliedTaskIDs) == failure.after { + return result, failure.err + } + } + return result, nil +} + +func TestServiceDependencyAddRemoveDryRunAndIdempotence(t *testing.T) { + alpha := graphRecord("alpha-prerequisite", domain.StatusCompleted) + charlie := graphRecord("charlie-prerequisite", domain.StatusCompleted) + dependent := graphRecord("dependent", domain.StatusReadyToStart, charlie.ID) + store := &graphOperationStore{fakeStore: fakeStore{tasks: []domain.Task{dependent, charlie, alpha}}} + svc := NewService(store) + + dry, err := svc.AddTaskDependencies("DEPEN", []string{"ALPHA", charlie.ID}, true) + if err != nil { + t.Fatal(err) + } + if !dry.Changed || !dry.DryRun || len(dry.PlannedTaskIDs) != 1 || len(dry.AppliedTaskIDs) != 0 { + t.Fatalf("dry-run receipt = %+v", dry) + } + if !slices.Equal(store.tasks[0].DependsOn, []string{charlie.ID}) { + t.Fatalf("dry run changed store: %v", store.tasks[0].DependsOn) + } + + added, err := svc.AddTaskDependencies(dependent.ID, []string{charlie.Slug, alpha.Slug}, false) + if err != nil { + t.Fatal(err) + } + wantDependencies := []string{alpha.ID, charlie.ID} + slices.Sort(wantDependencies) + if !added.Changed || !slices.Equal(added.AppliedTaskIDs, []string{dependent.ID}) || + !slices.Equal(store.tasks[0].DependsOn, wantDependencies) { + t.Fatalf("add receipt=%+v dependencies=%v", added, store.tasks[0].DependsOn) + } + if len(added.Edges) != 2 || added.Edges[0].Outcome != "added" || added.Edges[1].Outcome != "skipped" { + // Edge outcomes are canonical-ID sorted; determine the semantic counts below + // rather than coupling this assertion to hash-derived ID order. + outcomes := make(map[string]string) + for _, edge := range added.Edges { + outcomes[edge.PrerequisiteID] = edge.Outcome + } + if outcomes[alpha.ID] != "added" || outcomes[charlie.ID] != "skipped" { + t.Fatalf("edge outcomes = %+v", added.Edges) + } + } + + noop, err := svc.AddTaskDependencies(dependent.Slug, []string{alpha.Slug}, false) + if err != nil || noop.Changed || len(noop.AppliedTaskIDs) != 0 || noop.Edges[0].Outcome != "skipped" { + t.Fatalf("idempotent add = %+v, %v", noop, err) + } + removed, err := svc.RemoveTaskDependencies(dependent.Slug, []string{alpha.Slug}, false) + if err != nil || !removed.Changed || removed.Edges[0].Outcome != "removed" || + !slices.Equal(store.tasks[0].DependsOn, []string{charlie.ID}) { + t.Fatalf("remove = %+v, %v; dependencies=%v", removed, err, store.tasks[0].DependsOn) + } +} + +func TestServiceDependencyMutationRejectsAmbiguousDuplicateSelfAndCycle(t *testing.T) { + alpha := graphRecord("add-retry-alpha", domain.StatusReadyToStart) + atom := graphRecord("add-retry-atom", domain.StatusReadyToStart) + dependent := graphRecord("dependent", domain.StatusReadyToStart) + store := &graphOperationStore{fakeStore: fakeStore{tasks: []domain.Task{dependent, atom, alpha}}} + svc := NewService(store) + + if _, err := svc.AddTaskDependencies(dependent.Slug, []string{"add-retry"}, false); !errors.Is(err, domain.ErrAmbiguous) { + t.Fatalf("ambiguous prerequisite = %v", err) + } + if _, err := svc.AddTaskDependencies(dependent.Slug, []string{alpha.Slug, alpha.ID}, false); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("duplicate canonical prerequisite = %v", err) + } + if _, err := svc.AddTaskDependencies(dependent.Slug, []string{dependent.ID}, false); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("self dependency = %v", err) + } + if _, err := svc.AddTaskDependencies(dependent.Slug, []string{alpha.Slug}, false); err != nil { + t.Fatal(err) + } + if _, err := svc.AddTaskDependencies(alpha.Slug, []string{dependent.Slug}, false); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("cycle creation = %v", err) + } +} + +func TestServiceDependencyMigrationConvergesLegacyVocabulary(t *testing.T) { + prerequisite := graphRecord("legacy-prerequisite", domain.StatusCompleted) + second := graphRecord("legacy-second", domain.StatusCompleted) + dependent := graphRecord("legacy-dependent", domain.StatusReadyToStart) + prerequisite.LegacyBlocks = []string{dependent.Slug} + dependent.LegacyBlockedBy = []string{prerequisite.Slug} + dependent.LegacyDependencies = []string{second.ID} + store := &graphOperationStore{fakeStore: fakeStore{tasks: []domain.Task{prerequisite, second, dependent}}} + svc := NewService(store) + + receipt, err := svc.MigrateTaskDependencies(false) + if err != nil { + t.Fatal(err) + } + if !receipt.Changed || len(receipt.ClearedLegacyFields) != 3 || len(receipt.AppliedTaskIDs) != 2 { + t.Fatalf("migration receipt = %+v", receipt) + } + for _, task := range store.tasks { + if len(task.LegacyBlockedBy)+len(task.LegacyDependencies)+len(task.LegacyBlocks) != 0 { + t.Fatalf("legacy fields remain on %+v", task) + } + if task.ID == dependent.ID { + want := []string{prerequisite.ID, second.ID} + slices.Sort(want) + if !slices.Equal(task.DependsOn, want) { + t.Fatalf("migrated dependencies = %v, want %v", task.DependsOn, want) + } + } + } + if graph := NewTaskGraph(store.tasks, nil); graph.Health() != GraphHealthy { + t.Fatalf("migration did not converge to healthy graph: %s %+v", graph.Health(), graph.Problems()) + } + noop, err := svc.MigrateTaskDependencies(false) + if err != nil || noop.Changed || len(noop.AppliedTaskIDs) != 0 { + t.Fatalf("migration rerun = %+v, %v", noop, err) + } +} + +func TestServiceDependencyMutationRetriesOnlyBeforeDurablePrefix(t *testing.T) { + prerequisite := graphRecord("retry-prerequisite", domain.StatusCompleted) + dependent := graphRecord("retry-dependent", domain.StatusReadyToStart) + store := &graphOperationStore{ + fakeStore: fakeStore{tasks: []domain.Task{dependent, prerequisite}}, + failures: []graphMutationFailure{{err: domain.ErrConflict}}, + } + svc := NewService(store, WithRetry(2, func(int) {})) + receipt, err := svc.AddTaskDependencies(dependent.Slug, []string{prerequisite.Slug}, false) + if err != nil || store.calls != 2 || !slices.Equal(receipt.AppliedTaskIDs, []string{dependent.ID}) { + t.Fatalf("pre-write retry receipt=%+v calls=%d err=%v", receipt, store.calls, err) + } + + legacyOwner := graphRecord("prefix-owner", domain.StatusCompleted) + legacyDependent := graphRecord("prefix-dependent", domain.StatusReadyToStart) + legacyOwner.LegacyBlocks = []string{legacyDependent.Slug} + legacyDependent.LegacyBlockedBy = []string{legacyOwner.Slug} + partialStore := &graphOperationStore{ + fakeStore: fakeStore{tasks: []domain.Task{legacyOwner, legacyDependent}}, + failures: []graphMutationFailure{{err: domain.ErrConflict, after: 1}}, + } + partialSvc := NewService(partialStore, WithRetry(4, func(int) {})) + partial, err := partialSvc.MigrateTaskDependencies(false) + var failure *DependencyMutationFailure + if !errors.Is(err, domain.ErrConflict) || !errors.As(err, &failure) || partialStore.calls != 1 { + t.Fatalf("partial mutation calls=%d receipt=%+v err=%v", partialStore.calls, partial, err) + } + if len(partial.AppliedTaskIDs) != 1 || len(partial.RemainingTaskIDs) != 1 || + !reflect.DeepEqual(partial, failure.Receipt) { + t.Fatalf("typed durable-prefix receipt=%+v failure=%+v", partial, failure.Receipt) + } +} + +func TestServiceTaskGraphQueriesExplainCurrentSnapshot(t *testing.T) { + root := graphRecord("query-root", domain.StatusReadyToStart) + middle := graphRecord("query-middle", domain.StatusCompleted, root.ID) + target := graphRecord("query-target", domain.StatusReadyToStart, middle.ID) + store := &graphOperationStore{fakeStore: fakeStore{tasks: []domain.Task{target, middle, root}}} + svc := NewService(store) + + frontier, err := svc.TaskBlockers(target.Slug, false) + if err != nil || frontier.Projection != "frontier" || len(frontier.Blockers) != 1 || + frontier.Blockers[0].Blocker.TaskID != root.ID || frontier.Blockers[0].Blocker.Direct || + frontier.State.Gate != GateBlocked || frontier.State.Eligible { + t.Fatalf("frontier = %+v, %v", frontier, err) + } + causal, err := svc.TaskBlockers(target.Slug, true) + if err != nil || causal.Projection != "causal" || len(causal.Blockers) != 2 { + t.Fatalf("causal = %+v, %v", causal, err) + } + unblocks, err := svc.TaskUnblocks(root.Slug) + if err != nil || len(unblocks.Unblocks) != 2 || unblocks.State.Role != RoleCandidate { + t.Fatalf("unblocks = %+v, %v", unblocks, err) + } + for _, detail := range unblocks.Unblocks { + if detail.Impact.TaskID == target.ID && !slices.Equal(detail.Impact.Path, []string{root.ID, middle.ID, target.ID}) { + t.Fatalf("target downstream path = %v", detail.Impact.Path) + } + } +} diff --git a/internal/core/listtasks_test.go b/internal/core/listtasks_test.go index 96b45c0c..82d9e445 100644 --- a/internal/core/listtasks_test.go +++ b/internal/core/listtasks_test.go @@ -2,6 +2,7 @@ package core import ( "errors" + "slices" "testing" "github.com/andy-esch/taskflow/internal/domain" @@ -70,3 +71,28 @@ func TestService_ListTasks_EmptyEpicFilterSkipsEpicScan(t *testing.T) { t.Errorf("no epic filter must not consult ListEpics: %v", err) } } + +func TestService_ListTasks_UnblockedUsesStrictGraphAndFailsClosed(t *testing.T) { + completed := graphRecord("completed-prerequisite", domain.StatusCompleted) + blocked := graphRecord("blocked-candidate", domain.StatusReadyToStart) + eligible := graphRecord("eligible-candidate", domain.StatusReadyToStart, completed.ID) + store := &fakeStore{tasks: []domain.Task{blocked, eligible, completed}} + got, problems, err := NewService(store).ListTasks(TaskFilter{Unblocked: true}) + if err != nil || len(problems) != 0 { + t.Fatalf("healthy --unblocked = %v, problems=%v", err, problems) + } + want := []string{blocked.ID, eligible.ID} + gotIDs := make([]string, len(got)) + for i := range got { + gotIDs[i] = got[i].ID + } + if !slices.Equal(gotIDs, want) { + t.Fatalf("eligible IDs = %v, want %v", gotIDs, want) + } + + broken := graphRecord("broken-candidate", domain.StatusReadyToStart, "not-a-stable-id") + store.tasks = append(store.tasks, broken) + if _, _, err := NewService(store).ListTasks(TaskFilter{Unblocked: true}); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("broken --unblocked = %v, want ErrValidation", err) + } +} diff --git a/internal/core/service.go b/internal/core/service.go index 73620018..c0d45311 100644 --- a/internal/core/service.go +++ b/internal/core/service.go @@ -14,10 +14,11 @@ import ( // It has no fs and no cobra, so it is testable in isolation and reused by both // primary adapters (the cli and the tui). type Service struct { - store Store - templates TemplateSource - now func() time.Time // wall clock, injectable for deterministic snooze/revisit queries - newID func() string // stable-id mint (default id.New), injectable so created-file tests are deterministic + store Store + graphMutations TaskGraphMutationStore + templates TemplateSource + now func() time.Time // wall clock, injectable for deterministic snooze/revisit queries + newID func() string // stable-id mint (default id.New), injectable so created-file tests are deterministic // newIDAt mints an id stamped with a GIVEN time (default id.NewAt) — for an entity // whose id must encode its own declared date rather than "now", so lexical id order // stays authorship order. Research uses it (its id is minted from `created`, which @@ -72,10 +73,25 @@ func WithIDGen(gen func() string) Option { } } +// WithTaskGraphMutationStore supplies the use-case-specific guarded dependency +// mutation capability. NewService discovers it automatically when the ordinary +// Store also implements the capability (the production FS does); the option is +// primarily for adapters/tests that keep read and mutation ports separate. +func WithTaskGraphMutationStore(store TaskGraphMutationStore) Option { + return func(s *Service) { + if store != nil { + s.graphMutations = store + } + } +} + // NewService wires the core to its store; templates default to the built-in // source unless WithTemplateSource overrides it. func NewService(store Store, opts ...Option) *Service { s := &Service{store: store, templates: builtinTemplates{}, now: time.Now, newID: id.New, newIDAt: id.NewAt, maxRetries: defaultMaxRetries, retrySleep: defaultRetrySleep} + if mutations, ok := store.(TaskGraphMutationStore); ok { + s.graphMutations = mutations + } for _, opt := range opts { opt(s) } @@ -380,10 +396,13 @@ func dependencyLintIssues(graph *TaskGraph) map[string][]domain.Issue { parts = append(parts, fmt.Sprintf("%q is ambiguous across %s", ref.Value, strings.Join(ref.CandidateIDs, ", "))) } } + message := strings.Join(parts, "; ") + if message == "" { + message = "field is present but empty" + } out[diagnostic.TaskPath] = append(out[diagnostic.TaskPath], domain.Issue{ Field: diagnostic.Field, Severity: severity, - Message: fmt.Sprintf("legacy dependency field: %s; canonical migration is intentionally deferred to guarded dependency operations", - strings.Join(parts, "; ")), + Message: fmt.Sprintf("legacy dependency field: %s; run `tskflwctl task depend migrate`", message), }) } for _, taskID := range graph.TaskIDs() { diff --git a/internal/core/service_task.go b/internal/core/service_task.go index 25182b90..2266b0d4 100644 --- a/internal/core/service_task.go +++ b/internal/core/service_task.go @@ -16,6 +16,7 @@ type TaskFilter struct { Tag string All bool RevisitDue bool // only deferred tasks whose revisit_at (snooze-until) date has arrived + Unblocked bool // only tasks whose strict graph state is derived Eligible } // ListTasks returns tasks matching the filter, plus any per-file load problems. @@ -42,6 +43,14 @@ func (s *Service) ListTasks(f TaskFilter) ([]domain.Task, []domain.FileProblem, if err != nil { return nil, nil, err } + var graph *TaskGraph + if f.Unblocked { + graph = NewTaskGraph(all, problems) + if graph.Health() != GraphHealthy { + return nil, problems, fmt.Errorf("%w: task list --unblocked requires a healthy repository task graph; health=%s: %s", + domain.ErrValidation, graph.Health(), taskGraphHealthDetail(graph)) + } + } // --revisit-due narrows to deferred tasks, so it opts out of the active-only // default (deferred is inactive) just like an explicit --status does. activeOnly := f.Status == "" && !f.All && !f.RevisitDue @@ -67,6 +76,9 @@ func (s *Service) ListTasks(f TaskFilter) ([]domain.Task, []domain.FileProblem, if f.Tag != "" && !hasTag(t.Tags, f.Tag) { continue } + if f.Unblocked && !graph.State(t.ID).Eligible { + continue + } out = append(out, t) } return out, problems, nil @@ -319,9 +331,9 @@ func (s *Service) SetFields(slug string, updates map[string]any, force, dryRun b func graphFieldDirection(field string) string { if field == "depends_on" { - return " (`task depend add/remove` once available)" + return "; use `tskflwctl task depend add` or `tskflwctl task depend remove`" } - return "; legacy dependency fields are removed only by the guarded migration" + return "; remove legacy dependency fields with `tskflwctl task depend migrate`" } // unknownFieldErr is the shared rejection for a field outside the registry, used diff --git a/internal/domain/task.go b/internal/domain/task.go index 4e91a9e5..da0f7278 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -51,4 +51,8 @@ type Task struct { LegacyBlockedBy []string `yaml:"blocked_by,omitempty"` LegacyDependencies []string `yaml:"dependencies,omitempty"` LegacyBlocks []string `yaml:"blocks,omitempty"` + // LegacyDependencyFields preserves field presence separately from values so an + // explicitly empty legacy key remains diagnosable and migratable. Values are + // the canonical field names and are populated by the store parser. + LegacyDependencyFields []string `yaml:"-"` } diff --git a/internal/store/dependency_operations_test.go b/internal/store/dependency_operations_test.go new file mode 100644 index 00000000..087bd884 --- /dev/null +++ b/internal/store/dependency_operations_test.go @@ -0,0 +1,208 @@ +package store + +import ( + "errors" + "fmt" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/andy-esch/taskflow/internal/core" + "github.com/andy-esch/taskflow/internal/domain" + "github.com/andy-esch/taskflow/internal/testutil" +) + +func TestDependencyMigrationPreservesBodyCommentsAndConverges(t *testing.T) { + root := t.TempDir() + prerequisiteID := testutil.TaskID("legacy-prerequisite") + secondID := testutil.TaskID("legacy-second") + dependentID := testutil.TaskID("legacy-dependent") + prerequisitePath := writeGraphMutationTask(t, root, "legacy-prerequisite", domain.StatusCompleted, nil, + "blocks: [legacy-dependent]\ncustom_key: keep-me # keep this comment\n") + dependentPath := writeGraphMutationTask(t, root, "legacy-dependent", domain.StatusReadyToStart, nil, + "blocked_by: [legacy-prerequisite]\ndependencies: ["+secondID+"]\n") + writeGraphMutationTask(t, root, "legacy-second", domain.StatusCompleted, nil, "") + + svc := core.NewService(NewFS(root), core.WithClock(func() time.Time { return graphMutationNow })) + receipt, err := svc.MigrateTaskDependencies(false) + if err != nil { + t.Fatal(err) + } + if !receipt.Changed || len(receipt.ClearedLegacyFields) != 3 || len(receipt.AppliedTaskIDs) != 2 { + t.Fatalf("migration receipt = %+v", receipt) + } + for _, path := range []string{prerequisitePath, dependentPath} { + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + for _, legacy := range []string{"blocked_by:", "dependencies:", "blocks:"} { + if strings.Contains(string(content), legacy) { + t.Fatalf("legacy field %q remains in %s:\n%s", legacy, path, content) + } + } + if !strings.Contains(string(content), "Body stays intact.") || !strings.Contains(string(content), "updated_at: \"2026-08-27\"") { + t.Fatalf("migration did not preserve body/stamp update in %s:\n%s", path, content) + } + } + prerequisiteContent, _ := os.ReadFile(prerequisitePath) + if !strings.Contains(string(prerequisiteContent), "custom_key: keep-me # keep this comment") { + t.Fatalf("frontmatter comment was lost:\n%s", prerequisiteContent) + } + dependent, _, err := NewFS(root).GetTask(dependentID) + if err != nil { + t.Fatal(err) + } + want := []string{prerequisiteID, secondID} + slices.Sort(want) + if !slices.Equal(dependent.DependsOn, want) { + t.Fatalf("depends_on = %v, want %v", dependent.DependsOn, want) + } + graph, err := core.LoadTaskGraph(NewFS(root)) + if err != nil || graph.Health() != core.GraphHealthy { + t.Fatalf("post-migration graph = %v health=%s", err, graph.Health()) + } +} + +func TestDependencyMigrationFailureCarriesDurablePrefixAndRerunConverges(t *testing.T) { + root := t.TempDir() + writeGraphMutationTask(t, root, "prefix-prerequisite", domain.StatusCompleted, nil, + "blocks: [prefix-dependent]\n") + writeGraphMutationTask(t, root, "prefix-dependent", domain.StatusReadyToStart, nil, + "blocked_by: [prefix-prerequisite]\n") + svc := core.NewService(NewFS(root), core.WithClock(func() time.Time { return graphMutationNow })) + + original := testHookAfterGraphWrite + defer func() { testHookAfterGraphWrite = original }() + testHookAfterGraphWrite = func(string) error { + testHookAfterGraphWrite = nil + return errors.New("injected interruption") + } + partial, err := svc.MigrateTaskDependencies(false) + var failure *core.DependencyMutationFailure + if !errors.As(err, &failure) || len(partial.AppliedTaskIDs) != 1 || len(partial.RemainingTaskIDs) != 1 { + t.Fatalf("partial receipt=%+v failure=%+v err=%v", partial, failure, err) + } + if !strings.Contains(err.Error(), "durable dependency prefix") { + t.Fatalf("human recovery guidance missing: %v", err) + } + + completed, err := svc.MigrateTaskDependencies(false) + if err != nil || !completed.Changed || len(completed.AppliedTaskIDs) != 1 { + t.Fatalf("convergent rerun=%+v err=%v", completed, err) + } + graph, loadErr := core.LoadTaskGraph(NewFS(root)) + if loadErr != nil || graph.Health() != core.GraphHealthy { + t.Fatalf("rerun graph health=%s err=%v", graph.Health(), loadErr) + } +} + +func TestDependencyMigrationBlocksOnlyWritesDependentBeforeClearingOwner(t *testing.T) { + root := t.TempDir() + ownerID := testutil.TaskID("blocks-only-owner") + dependentID := testutil.TaskID("blocks-only-dependent") + writeGraphMutationTask(t, root, "blocks-only-owner", domain.StatusCompleted, nil, + "blocks: [blocks-only-dependent]\n") + writeGraphMutationTask(t, root, "blocks-only-dependent", domain.StatusReadyToStart, nil, "") + svc := core.NewService(NewFS(root), core.WithClock(func() time.Time { return graphMutationNow })) + + original := testHookAfterGraphWrite + defer func() { testHookAfterGraphWrite = original }() + testHookAfterGraphWrite = func(string) error { + testHookAfterGraphWrite = nil + return errors.New("injected interruption") + } + partial, err := svc.MigrateTaskDependencies(false) + if err == nil || !slices.Equal(partial.PlannedTaskIDs, []string{dependentID, ownerID}) || + !slices.Equal(partial.AppliedTaskIDs, []string{dependentID}) { + t.Fatalf("blocks-only prefix receipt=%+v err=%v", partial, err) + } + dependent, _, getErr := NewFS(root).GetTask(dependentID) + if getErr != nil || !slices.Equal(dependent.DependsOn, []string{ownerID}) { + t.Fatalf("dependent canonical prefix=%v err=%v", dependent.DependsOn, getErr) + } + owner, _, getErr := NewFS(root).GetTask(ownerID) + if getErr != nil || !slices.Equal(owner.LegacyBlocks, []string{"blocks-only-dependent"}) { + t.Fatalf("owner legacy prefix=%v err=%v", owner.LegacyBlocks, getErr) + } + graph, loadErr := core.LoadTaskGraph(NewFS(root)) + if loadErr != nil || graph.Health() != core.GraphDegraded { + t.Fatalf("blocks-only prefix health=%s err=%v problems=%+v", graph.Health(), loadErr, graph.Problems()) + } + completed, err := svc.MigrateTaskDependencies(false) + if err != nil || !slices.Equal(completed.AppliedTaskIDs, []string{ownerID}) { + t.Fatalf("blocks-only rerun=%+v err=%v", completed, err) + } + graph, loadErr = core.LoadTaskGraph(NewFS(root)) + if loadErr != nil || graph.Health() != core.GraphHealthy { + t.Fatalf("blocks-only final health=%s err=%v", graph.Health(), loadErr) + } +} + +func TestDependencyMigrationClearsAndReportsPresentEmptyLegacyFields(t *testing.T) { + root := t.TempDir() + taskID := testutil.TaskID("empty-legacy-owner") + path := writeGraphMutationTask(t, root, "empty-legacy-owner", domain.StatusReadyToStart, nil, + "blocked_by: []\ndependencies: []\nblocks: []\n") + svc := core.NewService(NewFS(root), core.WithClock(func() time.Time { return graphMutationNow })) + receipt, err := svc.MigrateTaskDependencies(false) + if err != nil || !receipt.Changed || len(receipt.ClearedLegacyFields) != 3 || + !slices.Equal(receipt.AppliedTaskIDs, []string{taskID}) { + t.Fatalf("empty legacy migration=%+v err=%v", receipt, err) + } + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + for _, field := range []string{"blocked_by:", "dependencies:", "blocks:"} { + if strings.Contains(string(content), field) { + t.Fatalf("empty legacy field %s remains:\n%s", field, content) + } + } +} + +func TestDependencyMigrationEveryDurablePrefixStaysSoundAndResumes(t *testing.T) { + for failAfter := 1; failAfter <= 3; failAfter++ { + t.Run(fmt.Sprintf("after-%d", failAfter), func(t *testing.T) { + root := t.TempDir() + writeGraphMutationTask(t, root, "prefix-a", domain.StatusReadyToStart, nil, "blocked_by: [prefix-b]\n") + writeGraphMutationTask(t, root, "prefix-b", domain.StatusCompleted, nil, "blocked_by: [prefix-c]\n") + writeGraphMutationTask(t, root, "prefix-c", domain.StatusCompleted, nil, "blocked_by: [prefix-d]\n") + writeGraphMutationTask(t, root, "prefix-d", domain.StatusCompleted, nil, "") + svc := core.NewService(NewFS(root), core.WithClock(func() time.Time { return graphMutationNow })) + + original := testHookAfterGraphWrite + defer func() { testHookAfterGraphWrite = original }() + writes := 0 + testHookAfterGraphWrite = func(string) error { + writes++ + if writes == failAfter { + testHookAfterGraphWrite = nil + return errors.New("injected interruption") + } + return nil + } + partial, err := svc.MigrateTaskDependencies(false) + if err == nil || len(partial.AppliedTaskIDs) != failAfter || len(partial.RemainingTaskIDs) != 3-failAfter { + t.Fatalf("prefix %d receipt=%+v err=%v", failAfter, partial, err) + } + if failAfter == 3 && !strings.Contains(err.Error(), "all planned dependency task files were durably applied") { + t.Fatalf("final-write failure did not explain fully durable result: %v", err) + } + graph, loadErr := core.LoadTaskGraph(NewFS(root)) + if loadErr != nil || graph.Health() == core.GraphBroken { + t.Fatalf("prefix %d left broken graph: health=%s err=%v problems=%+v", failAfter, graph.Health(), loadErr, graph.Problems()) + } + completed, err := svc.MigrateTaskDependencies(false) + if err != nil || len(completed.AppliedTaskIDs) != 3-failAfter { + t.Fatalf("prefix %d rerun=%+v err=%v", failAfter, completed, err) + } + graph, loadErr = core.LoadTaskGraph(NewFS(root)) + if loadErr != nil || graph.Health() != core.GraphHealthy { + t.Fatalf("prefix %d rerun health=%s err=%v", failAfter, graph.Health(), loadErr) + } + }) + } +} diff --git a/internal/store/dependency_persistence_test.go b/internal/store/dependency_persistence_test.go index ca5b909f..78e23288 100644 --- a/internal/store/dependency_persistence_test.go +++ b/internal/store/dependency_persistence_test.go @@ -31,6 +31,9 @@ func TestTaskDependencyFieldsRoundTrip(t *testing.T) { !reflect.DeepEqual(task.LegacyBlocks, []string{"legacy-c"}) { t.Fatalf("legacy fields did not round-trip: %+v", task) } + if !reflect.DeepEqual(task.LegacyDependencyFields, []string{"blocked_by", "dependencies", "blocks"}) { + t.Fatalf("legacy field presence did not round-trip: %v", task.LegacyDependencyFields) + } } func TestCreateTaskRejectsDependenciesUntilGuardedCreationExists(t *testing.T) { diff --git a/internal/store/edit.go b/internal/store/edit.go index 6075e42e..1d3e848b 100644 --- a/internal/store/edit.go +++ b/internal/store/edit.go @@ -186,10 +186,13 @@ func (s *FS) EditTask(slug string, now time.Time, edit func(current string, prev } type taskDependencyFields struct { - dependsOn []string - blockedBy []string - dependencies []string - blocks []string + dependsOn []string + blockedBy []string + dependencies []string + blocks []string + blockedByPresent bool + dependenciesPresent bool + blocksPresent bool } func sortedCopy(values []string) []string { @@ -200,10 +203,13 @@ func sortedCopy(values []string) []string { func dependencyFieldsFromTask(task domain.Task) taskDependencyFields { return taskDependencyFields{ - dependsOn: sortedCopy(task.DependsOn), - blockedBy: sortedCopy(task.LegacyBlockedBy), - dependencies: sortedCopy(task.LegacyDependencies), - blocks: sortedCopy(task.LegacyBlocks), + dependsOn: sortedCopy(task.DependsOn), + blockedBy: sortedCopy(task.LegacyBlockedBy), + dependencies: sortedCopy(task.LegacyDependencies), + blocks: sortedCopy(task.LegacyBlocks), + blockedByPresent: slices.Contains(task.LegacyDependencyFields, "blocked_by") || len(task.LegacyBlockedBy) > 0, + dependenciesPresent: slices.Contains(task.LegacyDependencyFields, "dependencies") || len(task.LegacyDependencies) > 0, + blocksPresent: slices.Contains(task.LegacyDependencyFields, "blocks") || len(task.LegacyBlocks) > 0, } } @@ -211,7 +217,10 @@ func (fields taskDependencyFields) equal(other taskDependencyFields) bool { return slices.Equal(fields.dependsOn, other.dependsOn) && slices.Equal(fields.blockedBy, other.blockedBy) && slices.Equal(fields.dependencies, other.dependencies) && - slices.Equal(fields.blocks, other.blocks) + slices.Equal(fields.blocks, other.blocks) && + fields.blockedByPresent == other.blockedByPresent && + fields.dependenciesPresent == other.dependenciesPresent && + fields.blocksPresent == other.blocksPresent } // dependencyValues extracts every dependency-affecting field from an original @@ -233,11 +242,18 @@ func dependencyValues(content []byte) (taskDependencyFields, bool) { if err := yaml.Unmarshal(fm, &fields); err != nil { return taskDependencyFields{}, false } + var present map[string]yaml.Node + if err := yaml.Unmarshal(fm, &present); err != nil { + return taskDependencyFields{}, false + } return taskDependencyFields{ - dependsOn: sortedCopy(fields.DependsOn), - blockedBy: sortedCopy(fields.BlockedBy), - dependencies: sortedCopy(fields.Dependencies), - blocks: sortedCopy(fields.Blocks), + dependsOn: sortedCopy(fields.DependsOn), + blockedBy: sortedCopy(fields.BlockedBy), + dependencies: sortedCopy(fields.Dependencies), + blocks: sortedCopy(fields.Blocks), + blockedByPresent: present["blocked_by"].Kind != 0, + dependenciesPresent: present["dependencies"].Kind != 0, + blocksPresent: present["blocks"].Kind != 0, }, true } diff --git a/internal/store/fsstore.go b/internal/store/fsstore.go index 38562296..cb1b1f3c 100644 --- a/internal/store/fsstore.go +++ b/internal/store/fsstore.go @@ -418,6 +418,15 @@ func parseTask(content []byte, path string) (domain.Task, error) { if err := yaml.Unmarshal(fm, &t); err != nil { return domain.Task{}, fmt.Errorf("%w: %s", errBadFrontmatter, frontmatterError(fm, err)) } + var fields map[string]yaml.Node + if err := yaml.Unmarshal(fm, &fields); err != nil { + return domain.Task{}, fmt.Errorf("%w: %s", errBadFrontmatter, frontmatterError(fm, err)) + } + for _, field := range []string{"blocked_by", "dependencies", "blocks"} { + if _, present := fields[field]; present { + t.LegacyDependencyFields = append(t.LegacyDependencyFields, field) + } + } } // Status is authoritative in frontmatter (ADR-0003 §4). There is no directory to // fall back to under the flat layout, but an id-led file with a missing/unrecognized diff --git a/internal/store/graphmutation.go b/internal/store/graphmutation.go index da1a400c..b7f3a1d6 100644 --- a/internal/store/graphmutation.go +++ b/internal/store/graphmutation.go @@ -171,7 +171,7 @@ func (s *FS) materializeTaskGraphPlan(graph *core.TaskGraph, plan core.TaskGraph if !slices.Equal(parsed.DependsOn, planned.DependsOn) { return nil, fmt.Errorf("%w: dependency update for task %s did not materialize the planned canonical set", domain.ErrValidation, planned.TaskID) } - if planned.ClearLegacy && (len(parsed.LegacyBlockedBy) > 0 || len(parsed.LegacyDependencies) > 0 || len(parsed.LegacyBlocks) > 0) { + if planned.ClearLegacy && (len(parsed.LegacyBlockedBy) > 0 || len(parsed.LegacyDependencies) > 0 || len(parsed.LegacyBlocks) > 0 || len(parsed.LegacyDependencyFields) > 0) { return nil, fmt.Errorf("%w: dependency update for task %s did not clear legacy fields", domain.ErrValidation, planned.TaskID) } writes = append(writes, materializedTaskGraphWrite{ From 53732d7b4dea0fe5ed19942127822a0805c3fff7 Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Fri, 28 Aug 2026 07:58:40 -0400 Subject: [PATCH 2/3] feat(cli): expose dependency graph commands Add dependency mutation, blocker, downstream, and unblocked CLI surfaces with human and JSON receipts. Publish schema 1.51, generated references and goldens, queried-task state, structured partial-failure recovery, and user-facing dependency guidance. --- README.md | 30 +- docs/ARCHITECTURE.md | 16 +- docs/cli/tskflwctl_task.md | 3 + docs/cli/tskflwctl_task_blockers.md | 45 ++ docs/cli/tskflwctl_task_depend.md | 32 ++ docs/cli/tskflwctl_task_depend_add.md | 40 ++ docs/cli/tskflwctl_task_depend_migrate.md | 44 ++ docs/cli/tskflwctl_task_depend_remove.md | 40 ++ docs/cli/tskflwctl_task_list.md | 2 + docs/cli/tskflwctl_task_unblocks.md | 44 ++ internal/cli/exit.go | 5 + internal/cli/integration_golden_test.go | 3 + internal/cli/lint_test.go | 2 +- internal/cli/render/dependency.go | 132 +++++ internal/cli/render/dependency_test.go | 25 + internal/cli/task.go | 6 +- internal/cli/task_dependency.go | 152 ++++++ internal/cli/task_dependency_test.go | 356 +++++++++++++ .../golden/audit_findings_json.golden | 2 +- .../golden/audit_findings_open_json.golden | 2 +- .../testdata/golden/audit_info_json.golden | 2 +- .../testdata/golden/audit_path_json.golden | 2 +- .../cli/testdata/golden/board_json.golden | 2 +- .../testdata/golden/config_show_json.golden | 2 +- .../cli/testdata/golden/epic_list_json.golden | 2 +- .../cli/testdata/golden/epic_path_json.golden | 2 +- .../cli/testdata/golden/epic_show_json.golden | 2 +- internal/cli/testdata/golden/lint_json.golden | 2 +- .../cli/testdata/golden/schema_json.golden | 2 +- .../testdata/golden/schema_jsonschema.golden | 493 +++++++++++++++++- .../testdata/golden/schema_task_json.golden | 2 +- .../testdata/golden/status_all_json.golden | 2 +- .../cli/testdata/golden/status_json.golden | 2 +- .../golden/task_acceptance_json.golden | 2 +- .../testdata/golden/task_blockers_json.golden | 1 + .../cli/testdata/golden/task_info_json.golden | 2 +- .../cli/testdata/golden/task_list_json.golden | 2 +- .../golden/task_list_unblocked_json.golden | 1 + .../cli/testdata/golden/task_path_json.golden | 2 +- .../cli/testdata/golden/task_show_json.golden | 2 +- .../testdata/golden/task_unblocks_json.golden | 1 + .../testdata/golden/template_list_json.golden | 2 +- .../golden/template_show_security_json.golden | 2 +- internal/wire/dependency.go | 230 ++++++++ internal/wire/envelopes.go | 86 +-- internal/wire/envelopes_test.go | 37 ++ internal/wire/schema_comments.json | 14 + internal/wire/wire.go | 9 +- 48 files changed, 1812 insertions(+), 79 deletions(-) create mode 100644 docs/cli/tskflwctl_task_blockers.md create mode 100644 docs/cli/tskflwctl_task_depend.md create mode 100644 docs/cli/tskflwctl_task_depend_add.md create mode 100644 docs/cli/tskflwctl_task_depend_migrate.md create mode 100644 docs/cli/tskflwctl_task_depend_remove.md create mode 100644 docs/cli/tskflwctl_task_unblocks.md create mode 100644 internal/cli/render/dependency.go create mode 100644 internal/cli/render/dependency_test.go create mode 100644 internal/cli/task_dependency.go create mode 100644 internal/cli/task_dependency_test.go create mode 100644 internal/cli/testdata/golden/task_blockers_json.golden create mode 100644 internal/cli/testdata/golden/task_list_unblocked_json.golden create mode 100644 internal/cli/testdata/golden/task_unblocks_json.golden create mode 100644 internal/wire/dependency.go diff --git a/README.md b/README.md index 980f0b08..caf57e10 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,12 @@ tskflwctl research new "Storage options" --created 2026-06-24 # backdate: the i # read tskflwctl task list # active tasks (--all / --status / --epic / --tag) tskflwctl task list --revisit-due # deferred tasks whose snooze date has arrived +tskflwctl task list --unblocked # active tasks whose derived dependency gate is clear tskflwctl task show # metadata + body (--section / --frontmatter-only to narrow) tskflwctl task info --json # token-cheap metadata: path, status, epic, ac:{checked,total} (no body) tskflwctl task path # just the absolute file path — $EDITOR "$(tskflwctl task path )" +tskflwctl task blockers # actionable blocker frontier (--causal for the full closure) +tskflwctl task unblocks # all transitive downstream tasks and their current graph state tskflwctl epic list # rollup: done/total per epic tskflwctl epic show --section goal # epic body section (or --frontmatter-only); epic path for the file tskflwctl audit list # open audits (--all / --closed / --deferred) @@ -130,6 +133,9 @@ tskflwctl task ac # numbered acceptance criter tskflwctl task ac --tracked 3 --reason "carried by " # …or --defer/--wontfix/--na: why it is unmet, not just that it is tskflwctl task start|next|ready|complete|defer|deprecate ... # defer takes --until tskflwctl task defer --until 2026-09-01 # snooze (revisit_at); on a TTY, prompts for the date +tskflwctl task depend add --on ... # guarded repository-global edge add +tskflwctl task depend remove --on ... # idempotent guarded edge removal +tskflwctl task depend migrate # convert safe legacy dependency fields repo-wide tskflwctl audit finding --status "tracked by " --note "how" # status + resolution, one atomic write tskflwctl audit close|reopen|defer ... tskflwctl research set --description "…" --tags a,b # settable fields only; `schema research` lists them @@ -240,15 +246,21 @@ field in place and stamp the dates atomically — no file moves (`lint --fix` re-normalizes a hand-edited drift). Errors carry semantic exit codes — `10` not-found, `11` validation, `13` ambiguous, `14` conflict (e.g. a name already taken). -**Task-dependency read foundation.** Task frontmatter and JSON may carry `depends_on`, -a sorted set of stable task IDs representing repository-global prerequisites. This -release reads, validates, and explains that graph but intentionally exposes no public -dependency mutation command yet. Generic task creation, `task set` (even `--force`), -`task edit`, and `lint --fix` cannot add, remove, or reinterpret dependency fields; -guarded `task depend add/remove` operations are the next slice. Ordinary `lint` reports -all graph defects. Exactly resolved legacy `blocked_by`/`dependencies`/`blocks` values -are visible JSON/human advisories with exit zero, while missing, ambiguous, cyclic, or -self-referential legacy projections remain validation errors. See +**Task-dependency graph.** Task frontmatter and JSON may carry `depends_on`, a sorted +set of stable task IDs representing repository-global prerequisites. Guarded +`task depend add/remove` operations resolve references inside one authoritative +repository snapshot, reject broken or cyclic results, support authoritative dry runs, +and emit structured receipts. `task depend migrate` converts safe legacy +`blocked_by`/`dependencies`/`blocks` fields repository-wide and can resume after a +reported sound durable prefix; it also removes explicitly empty legacy keys. Resolved +legacy edges already participate in blocker/downstream reads and derived gates, while +health remains degraded until migration makes them canonical. Generic task creation, `task set` (even `--force`), +`task edit`, and `lint --fix` cannot add, remove, or reinterpret graph-owned fields. +Use `task blockers`, `task unblocks`, and `task list --unblocked` for explanatory and +dispatch-oriented reads; mutations and the eligibility selector fail closed on an +unsound graph, while diagnostic queries report the queried task's derived state, health, +and attributable problems. +Ordinary `lint` reports every graph defect. See [`ADR-0006`](./planning/adrs/0006-adopt-threads-as-task-dags.md) for the model and rollout. **Research** is the thinnest kind, and the omissions are the point: no status and diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a123fa9b..299dc58d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -146,10 +146,18 @@ adapter capabilities rather than leaked persistence. `TaskGraph` is an immutable read projection over one repository scan. It owns graph health (`healthy`/`degraded`/`broken`), SCC-based cycle attribution, derived lifecycle role and gate state, sound completion, topology, downstream impact, and separately - named causal-blocker and action-frontier projections. The analyzer uses only taskflow - types and owned deterministic algorithms; a graph package cannot leak into domain, - persistence, or wire contracts. Eligibility is read from derived state, never inferred - from an empty blocker list. + named causal-blocker and action-frontier projections. It also resolves ordinary task + references inside that immutable snapshot, so dependency planners never pre-resolve a + slug through persistence and carry a stale choice into the guarded callback. Service + dependency use cases emit taskflow-owned, adapter-neutral receipts for edge set + operations and the repository-wide legacy migration; typed failures retain any sound + durable prefix for explicit recovery. The analyzer uses only taskflow types and owned + deterministic algorithms; a graph package cannot leak into domain, persistence, or + wire contracts. Exactly resolved legacy edges participate in diagnostic traversal and + derived gates before migration, so a degraded snapshot cannot issue a false all-clear; + present-but-empty legacy keys still keep health degraded until migration removes them. + Eligibility is read from the queried task's explicit derived state, never inferred from + an empty blocker list, and `task list --unblocked` fails closed unless the snapshot is healthy. Per-space failures remain data in the projection; the CLI renders the complete sweep before applying its partial-failure exit policy. Pure; unit-testable without fs. - **`internal/store`** — the secondary adapter: tasks as diff --git a/docs/cli/tskflwctl_task.md b/docs/cli/tskflwctl_task.md index 6a4b7145..3835174f 100644 --- a/docs/cli/tskflwctl_task.md +++ b/docs/cli/tskflwctl_task.md @@ -28,8 +28,10 @@ Work with tasks * [tskflwctl](tskflwctl.md) - Local-first planning CLI (tasks, epics, audits, research) over markdown * [tskflwctl task ac](tskflwctl_task_ac.md) - List a task's acceptance criteria, or check/uncheck one by index * [tskflwctl task append](tskflwctl_task_append.md) - Append a section to a task's body (atomic; agent-facing) +* [tskflwctl task blockers](tskflwctl_task_blockers.md) - Explain the actionable blockers for a task * [tskflwctl task complete](tskflwctl_task_complete.md) - Move task(s) to completed * [tskflwctl task defer](tskflwctl_task_defer.md) - Move task(s) to deferred (optionally with a revisit date) +* [tskflwctl task depend](tskflwctl_task_depend.md) - Change repository-global task dependencies through the graph guard * [tskflwctl task deprecate](tskflwctl_task_deprecate.md) - Move task(s) to deprecated * [tskflwctl task edit](tskflwctl_task_edit.md) - Open a task in your editor (whole file; re-validated on save) * [tskflwctl task info](tskflwctl_task_info.md) - Show a task's metadata + file path + acceptance tally (no body) @@ -43,4 +45,5 @@ Work with tasks * [tskflwctl task set](tskflwctl_task_set.md) - Set one or more frontmatter fields (validated, single atomic write) * [tskflwctl task show](tskflwctl_task_show.md) - Show a task's metadata and body * [tskflwctl task start](tskflwctl_task_start.md) - Move task(s) to in-progress +* [tskflwctl task unblocks](tskflwctl_task_unblocks.md) - Show every task transitively downstream of this task diff --git a/docs/cli/tskflwctl_task_blockers.md b/docs/cli/tskflwctl_task_blockers.md new file mode 100644 index 00000000..848bf980 --- /dev/null +++ b/docs/cli/tskflwctl_task_blockers.md @@ -0,0 +1,45 @@ +## tskflwctl task blockers + +Explain the actionable blockers for a task + +### Synopsis + +Explain a task's current derived role, gate, eligibility, and actionable blocker frontier. --causal selects the full forensic closure. Resolved legacy constraints participate in both projections, while graph health still reports degraded until they are migrated. + +``` +tskflwctl task blockers [flags] +``` + +### Examples + +``` + tskflwctl task blockers deploy + tskflwctl task blockers deploy --causal --json +``` + +### Options + +``` + --causal show the full causal blocker closure instead of the actionable frontier + -h, --help help for blockers +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks + diff --git a/docs/cli/tskflwctl_task_depend.md b/docs/cli/tskflwctl_task_depend.md new file mode 100644 index 00000000..0f080962 --- /dev/null +++ b/docs/cli/tskflwctl_task_depend.md @@ -0,0 +1,32 @@ +## tskflwctl task depend + +Change repository-global task dependencies through the graph guard + +### Options + +``` + -h, --help help for depend +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks +* [tskflwctl task depend add](tskflwctl_task_depend_add.md) - Add one or more hard prerequisites +* [tskflwctl task depend migrate](tskflwctl_task_depend_migrate.md) - Convert all safe legacy dependency fields to canonical depends_on IDs +* [tskflwctl task depend remove](tskflwctl_task_depend_remove.md) - Remove one or more hard prerequisites + diff --git a/docs/cli/tskflwctl_task_depend_add.md b/docs/cli/tskflwctl_task_depend_add.md new file mode 100644 index 00000000..1f1965b9 --- /dev/null +++ b/docs/cli/tskflwctl_task_depend_add.md @@ -0,0 +1,40 @@ +## tskflwctl task depend add + +Add one or more hard prerequisites + +``` +tskflwctl task depend add [flags] +``` + +### Examples + +``` + tskflwctl task depend add deploy --on build --on verify +``` + +### Options + +``` + -h, --help help for add + --on strings prerequisite task reference (repeat or comma-separate) +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task depend](tskflwctl_task_depend.md) - Change repository-global task dependencies through the graph guard + diff --git a/docs/cli/tskflwctl_task_depend_migrate.md b/docs/cli/tskflwctl_task_depend_migrate.md new file mode 100644 index 00000000..61a71dd7 --- /dev/null +++ b/docs/cli/tskflwctl_task_depend_migrate.md @@ -0,0 +1,44 @@ +## tskflwctl task depend migrate + +Convert all safe legacy dependency fields to canonical depends_on IDs + +### Synopsis + +Convert every legacy blocked_by, dependencies, and blocks field occurrence to canonical depends_on IDs and remove the legacy keys. Present-but-empty legacy keys are also removed. The repository-wide plan writes dependents before legacy blocks owners so every durable prefix remains conservative and a retry converges after interruption. + +``` +tskflwctl task depend migrate [flags] +``` + +### Examples + +``` + tskflwctl task depend migrate --dry-run --json + tskflwctl task depend migrate +``` + +### Options + +``` + -h, --help help for migrate +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task depend](tskflwctl_task_depend.md) - Change repository-global task dependencies through the graph guard + diff --git a/docs/cli/tskflwctl_task_depend_remove.md b/docs/cli/tskflwctl_task_depend_remove.md new file mode 100644 index 00000000..a8e10168 --- /dev/null +++ b/docs/cli/tskflwctl_task_depend_remove.md @@ -0,0 +1,40 @@ +## tskflwctl task depend remove + +Remove one or more hard prerequisites + +``` +tskflwctl task depend remove [flags] +``` + +### Examples + +``` + tskflwctl task depend remove deploy --on build --on verify +``` + +### Options + +``` + -h, --help help for remove + --on strings prerequisite task reference (repeat or comma-separate) +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task depend](tskflwctl_task_depend.md) - Change repository-global task dependencies through the graph guard + diff --git a/docs/cli/tskflwctl_task_list.md b/docs/cli/tskflwctl_task_list.md index 7db1e594..240a3133 100644 --- a/docs/cli/tskflwctl_task_list.md +++ b/docs/cli/tskflwctl_task_list.md @@ -12,6 +12,7 @@ tskflwctl task list [flags] tskflwctl task list tskflwctl task list -q --tag tui | xargs tskflwctl task start tskflwctl task list -o table -c slug,status,epic + tskflwctl task list --unblocked --json tskflwctl task list --revisit-due -q | xargs tskflwctl task next # resume snoozed tasks now due ``` @@ -27,6 +28,7 @@ tskflwctl task list [flags] --revisit-due only deferred tasks whose revisit date has arrived (composes with --epic/--tag/-c) --status string filter by status --tag string filter by tag + --unblocked only tasks whose derived dependency state is eligible ``` ### Options inherited from parent commands diff --git a/docs/cli/tskflwctl_task_unblocks.md b/docs/cli/tskflwctl_task_unblocks.md new file mode 100644 index 00000000..7828bdd2 --- /dev/null +++ b/docs/cli/tskflwctl_task_unblocks.md @@ -0,0 +1,44 @@ +## tskflwctl task unblocks + +Show every task transitively downstream of this task + +### Synopsis + +Show the queried task's current derived state and every transitive downstream task with deterministic shortest paths. Resolved legacy constraints participate in the projection. This is current impact, not a promise that completing the source alone makes every result eligible. + +``` +tskflwctl task unblocks [flags] +``` + +### Examples + +``` + tskflwctl task unblocks build + tskflwctl task unblocks build --json +``` + +### Options + +``` + -h, --help help for unblocks +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path (conflicts with --space) + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --space string select a registered entry point by label (also TSKFLW_SPACE; conflicts with -C) + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks + diff --git a/internal/cli/exit.go b/internal/cli/exit.go index 3687981e..99f6bfce 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -77,6 +77,11 @@ func WriteError(w io.Writer, err error, asJSON bool) { payload := wire.ErrorEnvelope{SchemaVersion: wire.SchemaVersion} payload.Error.Code = errorCodeName(ExitCode(err)) payload.Error.Message = err.Error() + var dependencyErr *dependencyCommandFailure + if errors.As(err, &dependencyErr) { + details := wire.ToDependencyMutationJSON(dependencyErr.receipt, dependencyErr.workspace) + payload.Error.DependencyMutation = &details + } // Compact, like every other --json envelope (see wire.EncodeJSON): an agent // parsing the failure shouldn't pay for indentation either. _ = wire.EncodeJSON(w, payload) diff --git a/internal/cli/integration_golden_test.go b/internal/cli/integration_golden_test.go index 5a1c9035..e39ee680 100644 --- a/internal/cli/integration_golden_test.go +++ b/internal/cli/integration_golden_test.go @@ -70,6 +70,9 @@ func TestGolden_MachineContract(t *testing.T) { {"task_list_name", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "name"}, nil}, {"task_show_json", []string{"-C", fixtureRepo, "task", "show", "alpha-task", "--json"}, nil}, {"task_acceptance_json", []string{"-C", fixtureRepo, "task", "ac", "alpha-task", "--json"}, nil}, + {"task_blockers_json", []string{"-C", fixtureRepo, "task", "blockers", "alpha-task", "--json"}, nil}, + {"task_unblocks_json", []string{"-C", fixtureRepo, "task", "unblocks", "gamma-task", "--json"}, nil}, + {"task_list_unblocked_json", []string{"-C", fixtureRepo, "task", "list", "--unblocked", "--json"}, nil}, // task info / task path emit an absolute file path → redact the fixture root // so the committed golden is portable (pins schema_version + shape + tally). {"task_info_json", []string{"-C", fixtureRepo, "task", "info", "alpha-task", "--json"}, redact}, diff --git a/internal/cli/lint_test.go b/internal/cli/lint_test.go index 64940bbc..a273ed2d 100644 --- a/internal/cli/lint_test.go +++ b/internal/cli/lint_test.go @@ -90,7 +90,7 @@ func TestLintReportsLegacyAndCanonicalDependencyDefects(t *testing.T) { t.Fatalf("dependency defects must fail ordinary lint with exit 11, got %v", err) } for _, want := range []string{ - "legacy dependency field", targetID, "guarded dependency operations", + "legacy dependency field", targetID, "tskflwctl task depend migrate", "cannot depend on itself", "advisory finding", } { if !strings.Contains(out, want) { diff --git a/internal/cli/render/dependency.go b/internal/cli/render/dependency.go new file mode 100644 index 00000000..f6ff45ee --- /dev/null +++ b/internal/cli/render/dependency.go @@ -0,0 +1,132 @@ +package render + +import ( + "fmt" + "io" + "strings" + + "github.com/andy-esch/taskflow/internal/core" + "github.com/andy-esch/taskflow/internal/wire" +) + +// DependencyMutationJSON writes the stable guarded dependency receipt. +func DependencyMutationJSON(w io.Writer, receipt core.DependencyMutationReceipt, workspace wire.WorkspaceJSON) error { + return wire.EncodeJSON(w, wire.ToDependencyMutationEnvelope(receipt, workspace)) +} + +// DependencyMutationHuman explains every edge outcome and migration prefix. +func DependencyMutationHuman(w io.Writer, st Style, receipt core.DependencyMutationReceipt) error { + if !receipt.Changed { + fmt.Fprintf(w, "%s dependency graph already satisfies the %s request\n", st.Dim("•"), receipt.Operation) + } + for _, edge := range receipt.Edges { + if edge.Outcome == "skipped" { + fmt.Fprintf(w, "%s %s %s -> %s (already satisfied)\n", st.Dim("•"), edge.Action, edge.PrerequisiteID, edge.DependentID) + continue + } + prefix := "✔" + verb := edge.Outcome + if receipt.DryRun { + prefix, verb = "◇", "would be "+edge.Outcome + } + fmt.Fprintf(w, "%s %s %s -> %s\n", st.Green(prefix), verb, edge.PrerequisiteID, edge.DependentID) + } + if len(receipt.ClearedLegacyFields) > 0 { + verb := "cleared" + prefix := "✔" + if receipt.DryRun { + verb = "would clear" + prefix = "◇" + } + for _, clear := range receipt.ClearedLegacyFields { + fmt.Fprintf(w, "%s %s %s on %s\n", st.Green(prefix), verb, clear.Field, clear.TaskID) + } + } + if len(receipt.AppliedTaskIDs) > 0 { + fmt.Fprintf(w, "%s\n", st.Dim("applied task files: "+strings.Join(receipt.AppliedTaskIDs, ", "))) + } else if receipt.DryRun && len(receipt.PlannedTaskIDs) > 0 { + fmt.Fprintf(w, "%s\n", st.Dim("planned task files: "+strings.Join(receipt.PlannedTaskIDs, ", "))) + } + return nil +} + +// TaskBlockersJSON writes the blocker diagnostic envelope. +func TaskBlockersJSON(w io.Writer, result core.TaskBlockersResult) error { + return wire.EncodeJSON(w, wire.ToTaskBlockersEnvelope(result)) +} + +// TaskBlockersHuman renders a compact explanatory blocker list. +func TaskBlockersHuman(w io.Writer, st Style, result core.TaskBlockersResult) error { + graphQueryHeader(w, st, result.TaskID, result.Task.Slug, result.State, result.Health, result.Projection) + if len(result.Blockers) == 0 { + fmt.Fprintf(w, "%s no blockers\n", st.Green("✔")) + } + for _, detail := range result.Blockers { + name := detail.Task.Slug + if name == "" { + name = detail.Blocker.TaskID + } + direct := "transitive" + if detail.Blocker.Direct { + direct = "direct" + } + fmt.Fprintf(w, "%s %s %s %s\n", st.Dim("•"), st.Bold(name), detail.Blocker.Reason, direct) + fmt.Fprintf(w, " %s\n", st.Dim(strings.Join(detail.Blocker.Path, " -> "))) + } + graphDiagnosticsHuman(w, st, result.Problems, result.Legacy) + return nil +} + +// TaskUnblocksJSON writes the downstream-impact envelope. +func TaskUnblocksJSON(w io.Writer, result core.TaskUnblocksResult) error { + return wire.EncodeJSON(w, wire.ToTaskUnblocksEnvelope(result)) +} + +// TaskUnblocksHuman renders transitive downstream impact without implying +// counterfactual eligibility. +func TaskUnblocksHuman(w io.Writer, st Style, result core.TaskUnblocksResult) error { + graphQueryHeader(w, st, result.TaskID, result.Task.Slug, result.State, result.Health, "downstream impact") + if len(result.Unblocks) == 0 { + fmt.Fprintf(w, "%s no downstream tasks\n", st.Dim("•")) + } + for _, detail := range result.Unblocks { + name := detail.Task.Slug + if name == "" { + name = detail.Impact.TaskID + } + direct := "transitive" + if detail.Impact.Direct { + direct = "direct" + } + fmt.Fprintf(w, "%s %s %s/%s %s\n", st.Dim("•"), st.Bold(name), detail.State.Role, detail.State.Gate, direct) + fmt.Fprintf(w, " %s\n", st.Dim(strings.Join(detail.Impact.Path, " -> "))) + } + graphDiagnosticsHuman(w, st, result.Problems, result.Legacy) + return nil +} + +func graphQueryHeader(w io.Writer, st Style, taskID, slug string, state core.TaskGraphState, health core.GraphHealth, projection string) { + name := slug + if name == "" { + name = taskID + } + fmt.Fprintf(w, "%s %s\n", st.Bold(name), st.Dim("("+taskID+")")) + fmt.Fprintf(w, "%s %s\n", st.Dim("graph:"), health) + fmt.Fprintf(w, "%s %s/%s eligible=%t\n", st.Dim("state:"), state.Role, state.Gate, state.Eligible) + fmt.Fprintf(w, "%s %s\n", st.Dim("view:"), projection) +} + +func graphDiagnosticsHuman(w io.Writer, st Style, problems []core.GraphProblem, legacy []core.LegacyDependencyDiagnostic) { + seen := make(map[string]bool, len(problems)) + for _, problem := range problems { + key := string(problem.Code) + "\x00" + problem.Message + if seen[key] { + continue + } + seen[key] = true + fmt.Fprintf(w, "%s %s: %s\n", st.Warn("⚠"), problem.Code, problem.Message) + } + for _, diagnostic := range legacy { + fmt.Fprintf(w, "%s legacy %s on %s; run task depend migrate\n", st.Warn("⚠"), diagnostic.Field, diagnostic.TaskID) + } +} diff --git a/internal/cli/render/dependency_test.go b/internal/cli/render/dependency_test.go new file mode 100644 index 00000000..2ac306de --- /dev/null +++ b/internal/cli/render/dependency_test.go @@ -0,0 +1,25 @@ +package render + +import ( + "bytes" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/core" +) + +func TestGraphDiagnosticsHumanDeduplicatesRepeatedRepositoryProblems(t *testing.T) { + problems := []core.GraphProblem{ + {Code: core.ProblemCycle, TaskID: "6g0000000001", Message: "dependency cycle: one -> two -> one"}, + {Code: core.ProblemCycle, TaskID: "6g0000000002", Message: "dependency cycle: one -> two -> one"}, + {Code: core.ProblemMissingDependency, TaskID: "6g0000000003", Message: "missing dependency"}, + } + var out bytes.Buffer + graphDiagnosticsHuman(&out, NewStyle(false), problems, nil) + if got := strings.Count(out.String(), "dependency cycle:"); got != 1 { + t.Fatalf("cycle rendered %d times:\n%s", got, out.String()) + } + if !strings.Contains(out.String(), "missing dependency") { + t.Fatalf("distinct repository problem was lost:\n%s", out.String()) + } +} diff --git a/internal/cli/task.go b/internal/cli/task.go index 3a60a34f..34e0b941 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -69,6 +69,9 @@ func newTaskCmd(app *App) *cobra.Command { newTaskAppendCmd(app), newTaskRenameCmd(app), newTaskMoveCmd(app), + newTaskDependCmd(app), + newTaskBlockersCmd(app), + newTaskUnblocksCmd(app), ) // Explicit transition verbs over the internal move engine (no enum to // hallucinate; per-verb intent), built from the shared lifecycle registry so @@ -196,7 +199,7 @@ func newTaskListCmd(app *App) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List tasks (active by default)", - Example: " tskflwctl task list\n tskflwctl task list -q --tag tui | xargs tskflwctl task start\n tskflwctl task list -o table -c slug,status,epic\n tskflwctl task list --revisit-due -q | xargs tskflwctl task next # resume snoozed tasks now due", + Example: " tskflwctl task list\n tskflwctl task list -q --tag tui | xargs tskflwctl task start\n tskflwctl task list -o table -c slug,status,epic\n tskflwctl task list --unblocked --json\n tskflwctl task list --revisit-due -q | xargs tskflwctl task next # resume snoozed tasks now due", Args: cobra.NoArgs, Annotations: map[string]string{"safety": "read-only"}, RunE: func(cmd *cobra.Command, _ []string) error { @@ -221,6 +224,7 @@ func newTaskListCmd(app *App) *cobra.Command { cmd.Flags().StringVar(&filter.Tag, "tag", "", "filter by tag") cmd.Flags().BoolVar(&filter.All, "all", false, "include completed/deprecated/deferred") cmd.Flags().BoolVar(&filter.RevisitDue, "revisit-due", false, "only deferred tasks whose revisit date has arrived (composes with --epic/--tag/-c)") + cmd.Flags().BoolVar(&filter.Unblocked, "unblocked", false, "only tasks whose derived dependency state is eligible") _ = cmd.RegisterFlagCompletionFunc("status", completeStatusValues) _ = cmd.RegisterFlagCompletionFunc("epic", app.completeEpicIDs) return cmd diff --git a/internal/cli/task_dependency.go b/internal/cli/task_dependency.go new file mode 100644 index 00000000..1109c633 --- /dev/null +++ b/internal/cli/task_dependency.go @@ -0,0 +1,152 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/andy-esch/taskflow/internal/cli/render" + "github.com/andy-esch/taskflow/internal/core" + "github.com/andy-esch/taskflow/internal/wire" +) + +// dependencyCommandFailure adds adapter-owned workspace identity to the typed +// core failure so WriteError can preserve a resumable mutation receipt under +// --json. Error/Unwrap keep ordinary classification and human output intact. +type dependencyCommandFailure struct { + cause error + receipt core.DependencyMutationReceipt + workspace wire.WorkspaceJSON +} + +func (e *dependencyCommandFailure) Error() string { return e.cause.Error() } +func (e *dependencyCommandFailure) Unwrap() error { return e.cause } + +func dependencyFailure(app *App, receipt core.DependencyMutationReceipt, err error) error { + if err == nil { + return nil + } + // Pre-write validation/conflict failures use the ordinary classified error + // envelope. Edge outcomes describe the intended semantic delta, so embedding + // them in a failed no-write receipt could falsely read as applied. Structured + // mutation recovery is necessary only once a durable prefix exists. + if len(receipt.AppliedTaskIDs) == 0 { + return err + } + return &dependencyCommandFailure{cause: err, receipt: receipt, workspace: app.workspace()} +} + +func newTaskDependCmd(app *App) *cobra.Command { + cmd := &cobra.Command{ + Use: "depend", + Short: "Change repository-global task dependencies through the graph guard", + } + cmd.AddCommand( + newTaskDependencyEdgeCmd(app, core.DependencyAdd), + newTaskDependencyEdgeCmd(app, core.DependencyRemove), + newTaskDependencyMigrateCmd(app), + ) + return cmd +} + +func newTaskDependencyEdgeCmd(app *App, operation core.DependencyOperation) *cobra.Command { + var prerequisites []string + verb := string(operation) + cmd := &cobra.Command{ + Use: verb + " ", + Short: fmt.Sprintf("%s one or more hard prerequisites", map[core.DependencyOperation]string{core.DependencyAdd: "Add", core.DependencyRemove: "Remove"}[operation]), + Example: fmt.Sprintf(" tskflwctl task depend %s deploy --on build --on verify", verb), + Args: cobra.ExactArgs(1), + Annotations: map[string]string{"safety": "mutating"}, + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(_ *cobra.Command, args []string) error { + var ( + receipt core.DependencyMutationReceipt + err error + ) + if operation == core.DependencyAdd { + receipt, err = app.Svc.AddTaskDependencies(args[0], prerequisites, app.DryRun) + } else { + receipt, err = app.Svc.RemoveTaskDependencies(args[0], prerequisites, app.DryRun) + } + if err != nil { + return dependencyFailure(app, receipt, err) + } + if app.JSON { + return render.DependencyMutationJSON(app.Out, receipt, app.workspace()) + } + return render.DependencyMutationHuman(app.Out, app.Style, receipt) + }, + } + cmd.Flags().StringSliceVar(&prerequisites, "on", nil, "prerequisite task reference (repeat or comma-separate)") + _ = cmd.RegisterFlagCompletionFunc("on", app.completeTaskSlugs) + return cmd +} + +func newTaskDependencyMigrateCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "migrate", + Short: "Convert all safe legacy dependency fields to canonical depends_on IDs", + Long: "Convert every legacy blocked_by, dependencies, and blocks field occurrence to canonical depends_on IDs and remove the legacy keys. Present-but-empty legacy keys are also removed. The repository-wide plan writes dependents before legacy blocks owners so every durable prefix remains conservative and a retry converges after interruption.", + Example: " tskflwctl task depend migrate --dry-run --json\n tskflwctl task depend migrate", + Args: cobra.NoArgs, + Annotations: map[string]string{"safety": "mutating"}, + RunE: func(_ *cobra.Command, _ []string) error { + receipt, err := app.Svc.MigrateTaskDependencies(app.DryRun) + if err != nil { + return dependencyFailure(app, receipt, err) + } + if app.JSON { + return render.DependencyMutationJSON(app.Out, receipt, app.workspace()) + } + return render.DependencyMutationHuman(app.Out, app.Style, receipt) + }, + } +} + +func newTaskBlockersCmd(app *App) *cobra.Command { + var causal bool + cmd := &cobra.Command{ + Use: "blockers ", + Short: "Explain the actionable blockers for a task", + Long: "Explain a task's current derived role, gate, eligibility, and actionable blocker frontier. --causal selects the full forensic closure. Resolved legacy constraints participate in both projections, while graph health still reports degraded until they are migrated.", + Example: " tskflwctl task blockers deploy\n tskflwctl task blockers deploy --causal --json", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(_ *cobra.Command, args []string) error { + result, err := app.Svc.TaskBlockers(args[0], causal) + if err != nil { + return err + } + if app.JSON { + return render.TaskBlockersJSON(app.Out, result) + } + return render.TaskBlockersHuman(app.Out, app.Style, result) + }, + } + cmd.Flags().BoolVar(&causal, "causal", false, "show the full causal blocker closure instead of the actionable frontier") + return cmd +} + +func newTaskUnblocksCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "unblocks ", + Short: "Show every task transitively downstream of this task", + Long: "Show the queried task's current derived state and every transitive downstream task with deterministic shortest paths. Resolved legacy constraints participate in the projection. This is current impact, not a promise that completing the source alone makes every result eligible.", + Example: " tskflwctl task unblocks build\n tskflwctl task unblocks build --json", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(_ *cobra.Command, args []string) error { + result, err := app.Svc.TaskUnblocks(args[0]) + if err != nil { + return err + } + if app.JSON { + return render.TaskUnblocksJSON(app.Out, result) + } + return render.TaskUnblocksHuman(app.Out, app.Style, result) + }, + } +} diff --git a/internal/cli/task_dependency_test.go b/internal/cli/task_dependency_test.go new file mode 100644 index 00000000..9982b898 --- /dev/null +++ b/internal/cli/task_dependency_test.go @@ -0,0 +1,356 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/core" + "github.com/andy-esch/taskflow/internal/domain" + "github.com/andy-esch/taskflow/internal/testutil" + "github.com/andy-esch/taskflow/internal/wire" +) + +type dependencyCLITask struct { + slug string + status domain.Status + dependsOn []string + legacyYAML string +} + +func dependencyCLIRepo(t *testing.T, tasks ...dependencyCLITask) string { + t.Helper() + repo := testutil.NewRepo(t) + for _, task := range tasks { + dependsOn := "" + if len(task.dependsOn) > 0 { + dependsOn = "depends_on: [" + strings.Join(task.dependsOn, ", ") + "]\n" + } + content := "---\n" + + "id: " + testutil.TaskID(task.slug) + "\n" + + "status: " + string(task.status) + "\n" + + "description: " + task.slug + "\n" + + "tags: [graph]\n" + dependsOn + task.legacyYAML + + "---\n# " + task.slug + "\n\nBody stays intact.\n" + repo.Task(string(task.status), task.slug+".md", content) + } + return repo.Root +} + +func TestTaskDependAddRemoveJSONDryRunAndNoop(t *testing.T) { + alphaID := testutil.TaskID("alpha-prerequisite") + dependentID := testutil.TaskID("dependent") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "alpha-prerequisite", status: domain.StatusCompleted}, + dependencyCLITask{slug: "dependent", status: domain.StatusReadyToStart}, + ) + dependentPath := filepath.Join(root, domain.TasksDir, dependentID+"-dependent.md") + + out, errOut, err := runIn(t, root, "task", "depend", "add", "DEPEN", "--on", "ALPHA", "--dry-run", "--json") + if err != nil || errOut != "" { + t.Fatalf("dry add: %v\nstdout=%s\nstderr=%s", err, out, errOut) + } + var dry wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &dry); err != nil { + t.Fatalf("decode dry receipt: %v\n%s", err, out) + } + if !dry.DryRun || !dry.Changed || len(dry.PlannedTaskIDs) != 1 || len(dry.AppliedTaskIDs) != 0 || dry.Workspace.PlanningRoot == "" { + t.Fatalf("dry receipt = %+v", dry) + } + content, _ := os.ReadFile(dependentPath) + if strings.Contains(string(content), "depends_on:") { + t.Fatalf("dry run wrote dependency:\n%s", content) + } + + out, errOut, err = runIn(t, root, "task", "depend", "add", "dependent", "--on", "alpha-prerequisite", "--json") + if err != nil || errOut != "" { + t.Fatalf("add: %v\nstdout=%s\nstderr=%s", err, out, errOut) + } + var added wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &added); err != nil { + t.Fatal(err) + } + if !added.Changed || added.Edges[0].Outcome != "added" || + added.Edges[0].DependentID != dependentID || added.Edges[0].PrerequisiteID != alphaID || + !slices.Equal(added.AppliedTaskIDs, []string{dependentID}) { + t.Fatalf("add receipt = %+v", added) + } + afterAdd, _ := os.ReadFile(dependentPath) + + out, _, err = runIn(t, root, "task", "depend", "add", dependentID, "--on", alphaID, "--json") + if err != nil { + t.Fatal(err) + } + var noop wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &noop); err != nil { + t.Fatal(err) + } + if noop.Changed || noop.Edges[0].Outcome != "skipped" || len(noop.AppliedTaskIDs) != 0 { + t.Fatalf("idempotent receipt = %+v", noop) + } + afterNoopAdd, _ := os.ReadFile(dependentPath) + if !bytes.Equal(afterAdd, afterNoopAdd) { + t.Fatalf("idempotent add changed task bytes:\n--- before ---\n%s\n--- after ---\n%s", afterAdd, afterNoopAdd) + } + + out, _, err = runIn(t, root, "task", "depend", "remove", "dependent", "--on", "alpha", "--json") + if err != nil { + t.Fatal(err) + } + var removed wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &removed); err != nil { + t.Fatal(err) + } + if !removed.Changed || removed.Edges[0].Outcome != "removed" { + t.Fatalf("remove receipt = %+v", removed) + } + content, _ = os.ReadFile(dependentPath) + if strings.Contains(string(content), "depends_on:") { + t.Fatalf("remove did not clear empty dependency field:\n%s", content) + } + afterRemove := append([]byte(nil), content...) + out, _, err = runIn(t, root, "task", "depend", "remove", "dependent", "--on", "alpha", "--json") + if err != nil { + t.Fatal(err) + } + var noopRemove wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &noopRemove); err != nil || noopRemove.Changed || noopRemove.Edges[0].Outcome != "skipped" { + t.Fatalf("idempotent remove=%+v decode=%v", noopRemove, err) + } + afterNoopRemove, _ := os.ReadFile(dependentPath) + if !bytes.Equal(afterRemove, afterNoopRemove) { + t.Fatalf("idempotent remove changed task bytes:\n--- before ---\n%s\n--- after ---\n%s", afterRemove, afterNoopRemove) + } +} + +func TestTaskDependAddRejectsCycleWithValidationExit(t *testing.T) { + alphaID := testutil.TaskID("cycle-alpha") + betaID := testutil.TaskID("cycle-beta") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "cycle-alpha", status: domain.StatusReadyToStart}, + dependencyCLITask{slug: "cycle-beta", status: domain.StatusReadyToStart, dependsOn: []string{alphaID}}, + ) + out, errOut, err := runIn(t, root, "task", "depend", "add", alphaID, "--on", betaID, "--json") + if err == nil || ExitCode(err) != 11 || out != "" { + t.Fatalf("cycle stdout=%q stderr=%q err=%v exit=%d", out, errOut, err, ExitCode(err)) + } + var errorOut bytes.Buffer + WriteError(&errorOut, err, true) + var envelope wire.ErrorEnvelope + if decodeErr := json.Unmarshal(errorOut.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode error: %v\n%s", decodeErr, errorOut.String()) + } + if envelope.Error.Code != "validation" || !strings.Contains(envelope.Error.Message, "cycle") { + t.Fatalf("cycle error envelope = %+v", envelope) + } + if envelope.Error.DependencyMutation != nil { + t.Fatalf("pre-write cycle failure must not imply an applied mutation: %+v", envelope.Error.DependencyMutation) + } +} + +func TestTaskDependHumanReceiptMatchesMutationSemantics(t *testing.T) { + alphaID := testutil.TaskID("human-alpha") + dependentID := testutil.TaskID("human-dependent") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "human-alpha", status: domain.StatusCompleted}, + dependencyCLITask{slug: "human-dependent", status: domain.StatusReadyToStart}, + ) + preview, _, err := runIn(t, root, "task", "depend", "add", "human-dependent", "--on", "human-alpha", "--dry-run") + if err != nil || !strings.Contains(preview, "would be added") || + !strings.Contains(preview, alphaID+" -> "+dependentID) || !strings.Contains(preview, "planned task files") { + t.Fatalf("human preview: %v\n%s", err, preview) + } + applied, _, err := runIn(t, root, "task", "depend", "add", "human-dependent", "--on", "human-alpha") + if err != nil || !strings.Contains(applied, "added") || !strings.Contains(applied, "applied task files: "+dependentID) { + t.Fatalf("human apply: %v\n%s", err, applied) + } + noop, _, err := runIn(t, root, "task", "depend", "add", "human-dependent", "--on", "human-alpha") + if err != nil || !strings.Contains(noop, "already satisfies") || !strings.Contains(noop, "already satisfied") { + t.Fatalf("human no-op: %v\n%s", err, noop) + } +} + +func TestTaskDependMigratePreservesContentAndIsIdempotent(t *testing.T) { + prerequisiteID := testutil.TaskID("legacy-prerequisite") + dependentID := testutil.TaskID("legacy-dependent") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "legacy-prerequisite", status: domain.StatusCompleted, legacyYAML: "blocks: [legacy-dependent]\ncustom_key: keep-me # preserve comment\n"}, + dependencyCLITask{slug: "legacy-dependent", status: domain.StatusReadyToStart, legacyYAML: "blocked_by: [legacy-prerequisite]\n"}, + ) + + out, _, err := runIn(t, root, "task", "depend", "migrate", "--dry-run", "--json") + if err != nil { + t.Fatal(err) + } + var dry wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &dry); err != nil || !dry.DryRun || len(dry.ClearedLegacyFields) != 2 { + t.Fatalf("dry migration=%+v decode=%v", dry, err) + } + out, _, err = runIn(t, root, "task", "depend", "migrate", "--json") + if err != nil { + t.Fatal(err) + } + var migrated wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &migrated); err != nil || len(migrated.AppliedTaskIDs) != 2 { + t.Fatalf("migration=%+v decode=%v", migrated, err) + } + for _, slug := range []string{"legacy-prerequisite", "legacy-dependent"} { + path := filepath.Join(root, domain.TasksDir, testutil.TaskID(slug)+"-"+slug+".md") + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if strings.Contains(string(content), "blocked_by:") || strings.Contains(string(content), "blocks:") || + !strings.Contains(string(content), "Body stays intact.") { + t.Fatalf("migration content for %s:\n%s", slug, content) + } + } + prerequisiteContent, _ := os.ReadFile(filepath.Join(root, domain.TasksDir, prerequisiteID+"-legacy-prerequisite.md")) + if !strings.Contains(string(prerequisiteContent), "custom_key: keep-me # preserve comment") { + t.Fatalf("custom frontmatter was not preserved:\n%s", prerequisiteContent) + } + dependentContent, _ := os.ReadFile(filepath.Join(root, domain.TasksDir, dependentID+"-legacy-dependent.md")) + if !strings.Contains(string(dependentContent), "depends_on: ["+prerequisiteID+"]") { + t.Fatalf("canonical edge missing:\n%s", dependentContent) + } + + out, _, err = runIn(t, root, "task", "depend", "migrate", "--json") + if err != nil { + t.Fatal(err) + } + var noop wire.DependencyMutationEnvelope + if err := json.Unmarshal([]byte(out), &noop); err != nil || noop.Changed || len(noop.AppliedTaskIDs) != 0 { + t.Fatalf("idempotent migration=%+v decode=%v", noop, err) + } +} + +func TestTaskGraphQueryCommandsAndUnblockedSelector(t *testing.T) { + rootID := testutil.TaskID("query-root") + middleID := testutil.TaskID("query-middle") + targetID := testutil.TaskID("query-target") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "query-root", status: domain.StatusReadyToStart}, + dependencyCLITask{slug: "query-middle", status: domain.StatusCompleted, dependsOn: []string{rootID}}, + dependencyCLITask{slug: "query-target", status: domain.StatusReadyToStart, dependsOn: []string{middleID}}, + ) + + out, _, err := runIn(t, root, "task", "blockers", "query-target", "--json") + if err != nil { + t.Fatal(err) + } + var frontier wire.TaskBlockersEnvelope + if err := json.Unmarshal([]byte(out), &frontier); err != nil || frontier.Projection != "frontier" || + len(frontier.Blockers) != 1 || frontier.Blockers[0].Task.TaskID != rootID || frontier.Blockers[0].Reason != "not-started" || + frontier.State.Gate != "blocked" || frontier.State.Eligible { + t.Fatalf("frontier=%+v decode=%v", frontier, err) + } + out, _, err = runIn(t, root, "task", "blockers", targetID, "--causal", "--json") + if err != nil { + t.Fatal(err) + } + var causal wire.TaskBlockersEnvelope + if err := json.Unmarshal([]byte(out), &causal); err != nil || causal.Projection != "causal" || len(causal.Blockers) != 2 { + t.Fatalf("causal=%+v decode=%v", causal, err) + } + out, _, err = runIn(t, root, "task", "unblocks", "query-root", "--json") + if err != nil { + t.Fatal(err) + } + var unblocks wire.TaskUnblocksEnvelope + if err := json.Unmarshal([]byte(out), &unblocks); err != nil || len(unblocks.Unblocks) != 2 || unblocks.State.Role != "candidate" { + t.Fatalf("unblocks=%+v decode=%v", unblocks, err) + } + for _, dependent := range unblocks.Unblocks { + if dependent.Task.TaskID == targetID && !slices.Equal(dependent.Path, []string{rootID, middleID, targetID}) { + t.Fatalf("target path = %v", dependent.Path) + } + } + + out, _, err = runIn(t, root, "task", "list", "--unblocked", "--json") + if err != nil { + t.Fatal(err) + } + var tasks wire.TasksEnvelope + if err := json.Unmarshal([]byte(out), &tasks); err != nil || len(tasks.Tasks) != 1 || tasks.Tasks[0].ID != rootID { + t.Fatalf("unblocked list=%+v decode=%v", tasks, err) + } + + human, _, err := runIn(t, root, "task", "blockers", "query-target") + if err != nil || !strings.Contains(human, "query-root") || !strings.Contains(human, "not-started") || !strings.Contains(human, rootID) { + t.Fatalf("human blocker parity: %v\n%s", err, human) + } + human, _, err = runIn(t, root, "task", "unblocks", "query-root") + if err != nil || !strings.Contains(human, "query-target") || !strings.Contains(human, "transitive") || + !strings.Contains(human, rootID+" -> "+middleID+" -> "+targetID) { + t.Fatalf("human downstream parity: %v\n%s", err, human) + } +} + +func TestTaskGraphQueriesProjectResolvedLegacyConstraints(t *testing.T) { + prerequisiteID := testutil.TaskID("legacy-query-prerequisite") + dependentID := testutil.TaskID("legacy-query-dependent") + root := dependencyCLIRepo(t, + dependencyCLITask{slug: "legacy-query-prerequisite", status: domain.StatusReadyToStart}, + dependencyCLITask{slug: "legacy-query-dependent", status: domain.StatusReadyToStart, legacyYAML: "blocked_by: [legacy-query-prerequisite]\n"}, + ) + out, _, err := runIn(t, root, "task", "blockers", "legacy-query-dependent", "--json") + if err != nil { + t.Fatal(err) + } + var blockers wire.TaskBlockersEnvelope + if err := json.Unmarshal([]byte(out), &blockers); err != nil || blockers.Health != "degraded" || + blockers.State.Gate != "blocked" || len(blockers.Blockers) != 1 || blockers.Blockers[0].Task.TaskID != prerequisiteID { + t.Fatalf("legacy blockers=%+v decode=%v", blockers, err) + } + out, _, err = runIn(t, root, "task", "unblocks", "legacy-query-prerequisite", "--json") + if err != nil { + t.Fatal(err) + } + var unblocks wire.TaskUnblocksEnvelope + if err := json.Unmarshal([]byte(out), &unblocks); err != nil || unblocks.Health != "degraded" || + len(unblocks.Unblocks) != 1 || unblocks.Unblocks[0].Task.TaskID != dependentID { + t.Fatalf("legacy unblocks=%+v decode=%v", unblocks, err) + } +} + +func TestTaskListUnblockedFailsClosedOnBrokenGraph(t *testing.T) { + root := dependencyCLIRepo(t, dependencyCLITask{ + slug: "broken", status: domain.StatusReadyToStart, dependsOn: []string{"not-a-stable-id"}, + }) + out, _, err := runIn(t, root, "task", "list", "--unblocked", "--json") + if err == nil || ExitCode(err) != 11 || out != "" || !strings.Contains(err.Error(), "requires a healthy") { + t.Fatalf("broken selector stdout=%q err=%v exit=%d", out, err, ExitCode(err)) + } +} + +func TestWriteErrorCarriesStructuredDependencyMutationRecovery(t *testing.T) { + receipt := core.DependencyMutationReceipt{ + Operation: core.DependencyMigrate, Changed: true, + PlannedTaskIDs: []string{"6g0000000001", "6g0000000002"}, + AppliedTaskIDs: []string{"6g0000000001"}, RemainingTaskIDs: []string{"6g0000000002"}, + } + err := &dependencyCommandFailure{ + cause: &core.DependencyMutationFailure{Cause: domain.ErrConflict, Receipt: receipt}, + receipt: receipt, workspace: wire.WorkspaceJSON{PlanningRoot: "/repo/planning", Source: wire.WorkspaceSourceConfig}, + } + var out bytes.Buffer + WriteError(&out, err, true) + var envelope wire.ErrorEnvelope + if decodeErr := json.Unmarshal(out.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode error envelope: %v\n%s", decodeErr, out.String()) + } + if envelope.Error.Code != "conflict" || envelope.Error.DependencyMutation == nil || + !slices.Equal(envelope.Error.DependencyMutation.AppliedTaskIDs, []string{"6g0000000001"}) || + !slices.Equal(envelope.Error.DependencyMutation.RemainingTaskIDs, []string{"6g0000000002"}) || + envelope.Error.DependencyMutation.Workspace.PlanningRoot != "/repo/planning" { + t.Fatalf("structured recovery envelope = %+v", envelope) + } + if !errors.Is(err, domain.ErrConflict) { + t.Fatal("dependency command wrapper did not preserve error classification") + } +} diff --git a/internal/cli/testdata/golden/audit_findings_json.golden b/internal/cli/testdata/golden/audit_findings_json.golden index 8dc8b1cd..06e36f30 100644 --- a/internal/cli/testdata/golden/audit_findings_json.golden +++ b/internal/cli/testdata/golden/audit_findings_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute","status_decoration":"2026-01-03 (PR #1)"}]} +{"schema_version":"1.51","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute","status_decoration":"2026-01-03 (PR #1)"}]} diff --git a/internal/cli/testdata/golden/audit_findings_open_json.golden b/internal/cli/testdata/golden/audit_findings_open_json.golden index 99f692b9..f2c2ffa0 100644 --- a/internal/cli/testdata/golden/audit_findings_open_json.golden +++ b/internal/cli/testdata/golden/audit_findings_open_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} +{"schema_version":"1.51","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} diff --git a/internal/cli/testdata/golden/audit_info_json.golden b/internal/cli/testdata/golden/audit_info_json.golden index dc96b624..c316add1 100644 --- a/internal/cli/testdata/golden/audit_info_json.golden +++ b/internal/cli/testdata/golden/audit_info_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}} +{"schema_version":"1.51","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}} diff --git a/internal/cli/testdata/golden/audit_path_json.golden b/internal/cli/testdata/golden/audit_path_json.golden index de77fd2f..a353c071 100644 --- a/internal/cli/testdata/golden/audit_path_json.golden +++ b/internal/cli/testdata/golden/audit_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"} +{"schema_version":"1.51","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"} diff --git a/internal/cli/testdata/golden/board_json.golden b/internal/cli/testdata/golden/board_json.golden index a86a6e66..9231c390 100644 --- a/internal/cli/testdata/golden/board_json.golden +++ b/internal/cli/testdata/golden/board_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} +{"schema_version":"1.51","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} diff --git a/internal/cli/testdata/golden/config_show_json.golden b/internal/cli/testdata/golden/config_show_json.golden index 4e6f99c0..bd0a3eba 100644 --- a/internal/cli/testdata/golden/config_show_json.golden +++ b/internal/cli/testdata/golden/config_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","repository":{"path":"/.tskflwctl.toml","dir":"","planning_root":"","mode":"scaffold","taskflow_root":".","id":"6fjangd7kvhz","tracked_repos":[],"theme":{"name":"catppuccin"},"pager":{"enabled":false,"command":"delta"},"pending_migrations":[]},"user":{"path":"/config.toml","exists":false,"theme":{},"pager":{},"registry_path":"/spaces.toml"},"effective":{"theme":{"value":"catppuccin","source":"repository"},"pager_enabled":{"value":false,"source":"repository"},"pager_command":{"value":"delta","source":"repository"}}} +{"schema_version":"1.51","repository":{"path":"/.tskflwctl.toml","dir":"","planning_root":"","mode":"scaffold","taskflow_root":".","id":"6fjangd7kvhz","tracked_repos":[],"theme":{"name":"catppuccin"},"pager":{"enabled":false,"command":"delta"},"pending_migrations":[]},"user":{"path":"/config.toml","exists":false,"theme":{},"pager":{},"registry_path":"/spaces.toml"},"effective":{"theme":{"value":"catppuccin","source":"repository"},"pager_enabled":{"value":false,"source":"repository"},"pager_command":{"value":"delta","source":"repository"}}} diff --git a/internal/cli/testdata/golden/epic_list_json.golden b/internal/cli/testdata/golden/epic_list_json.golden index e44a4ddc..998f39ca 100644 --- a/internal/cli/testdata/golden/epic_list_json.golden +++ b/internal/cli/testdata/golden/epic_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} +{"schema_version":"1.51","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} diff --git a/internal/cli/testdata/golden/epic_path_json.golden b/internal/cli/testdata/golden/epic_path_json.golden index ccdd00a2..b49acbdc 100644 --- a/internal/cli/testdata/golden/epic_path_json.golden +++ b/internal/cli/testdata/golden/epic_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","path":"/epics/01-fixture-epic.md"} +{"schema_version":"1.51","path":"/epics/01-fixture-epic.md"} diff --git a/internal/cli/testdata/golden/epic_show_json.golden b/internal/cli/testdata/golden/epic_show_json.golden index 97130d84..fa0635f3 100644 --- a/internal/cli/testdata/golden/epic_show_json.golden +++ b/internal/cli/testdata/golden/epic_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} +{"schema_version":"1.51","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} diff --git a/internal/cli/testdata/golden/lint_json.golden b/internal/cli/testdata/golden/lint_json.golden index 7610c12b..34fdfb25 100644 --- a/internal/cli/testdata/golden/lint_json.golden +++ b/internal/cli/testdata/golden/lint_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","unreadable":[],"issues":[]} +{"schema_version":"1.51","unreadable":[],"issues":[]} diff --git a/internal/cli/testdata/golden/schema_json.golden b/internal/cli/testdata/golden/schema_json.golden index 7653a64f..30cc1ed2 100644 --- a/internal/cli/testdata/golden/schema_json.golden +++ b/internal/cli/testdata/golden/schema_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","open","superseded","tracked","wontfix"],"criterion_states":["deferred","n/a","tracked","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"depends_on","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"research_fields":[{"name":"created","type":"date"},{"name":"description","type":"string"},{"name":"tags","type":"list"},{"name":"updated_at","type":"date"}],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit","research"]} +{"schema_version":"1.51","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","open","superseded","tracked","wontfix"],"criterion_states":["deferred","n/a","tracked","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"depends_on","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"research_fields":[{"name":"created","type":"date"},{"name":"description","type":"string"},{"name":"tags","type":"list"},{"name":"updated_at","type":"date"}],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit","research"]} diff --git a/internal/cli/testdata/golden/schema_jsonschema.golden b/internal/cli/testdata/golden/schema_jsonschema.golden index 6f5fed7f..6a732c7a 100644 --- a/internal/cli/testdata/golden/schema_jsonschema.golden +++ b/internal/cli/testdata/golden/schema_jsonschema.golden @@ -645,6 +645,155 @@ ], "description": "CriterionJSON is one acceptance-criteria checkbox for `task ac --list --json` — the list an agent then flips by index with `task ac --check/--uncheck`." }, + "DependencyEdgeOutcomeJSON": { + "properties": { + "dependent_id": { + "type": "string" + }, + "prerequisite_id": { + "type": "string" + }, + "action": { + "type": "string" + }, + "outcome": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "dependent_id", + "prerequisite_id", + "action", + "outcome" + ], + "description": "DependencyEdgeOutcomeJSON is one canonical edge result in a mutation receipt." + }, + "DependencyMutationEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "changed": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "edges": { + "items": { + "$ref": "#/$defs/DependencyEdgeOutcomeJSON" + }, + "type": "array" + }, + "cleared_legacy_fields": { + "items": { + "$ref": "#/$defs/LegacyFieldClearJSON" + }, + "type": "array" + }, + "planned_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "applied_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "remaining_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace": { + "$ref": "#/$defs/WorkspaceJSON" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "operation", + "changed", + "dry_run", + "edges", + "cleared_legacy_fields", + "planned_task_ids", + "applied_task_ids", + "remaining_task_ids", + "workspace" + ], + "description": "DependencyMutationEnvelope is the successful `task depend` machine receipt." + }, + "DependencyMutationJSON": { + "properties": { + "operation": { + "type": "string" + }, + "changed": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "edges": { + "items": { + "$ref": "#/$defs/DependencyEdgeOutcomeJSON" + }, + "type": "array" + }, + "cleared_legacy_fields": { + "items": { + "$ref": "#/$defs/LegacyFieldClearJSON" + }, + "type": "array" + }, + "planned_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "applied_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "remaining_task_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace": { + "$ref": "#/$defs/WorkspaceJSON" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "operation", + "changed", + "dry_run", + "edges", + "cleared_legacy_fields", + "planned_task_ids", + "applied_task_ids", + "remaining_task_ids", + "workspace" + ], + "description": "DependencyMutationJSON is the reusable dependency-mutation receipt payload." + }, "DoctorEnvelope": { "properties": { "schema_version": { @@ -952,6 +1101,9 @@ }, "message": { "type": "string" + }, + "dependency_mutation": { + "$ref": "#/$defs/DependencyMutationJSON" } }, "additionalProperties": false, @@ -1233,6 +1385,67 @@ ], "description": "FixResult records the auto-repairs applied (or proposed) for one file." }, + "GraphProblemJSON": { + "properties": { + "code": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "related_task_id": { + "type": "string" + }, + "field": { + "type": "string" + }, + "path": { + "type": "string" + }, + "message": { + "type": "string" + }, + "cycle": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "code", + "message" + ], + "description": "GraphProblemJSON is taskflow-owned repository-graph diagnostic data." + }, + "GraphTaskJSON": { + "properties": { + "task_id": { + "type": "string", + "description": "canonical stable task identifier" + }, + "slug": { + "type": "string", + "description": "human task slug when the task is readable" + }, + "status": { + "type": "string", + "description": "persisted lifecycle status when readable" + }, + "epic": { + "type": "string", + "description": "epic identifier when readable" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "task_id" + ], + "description": "GraphTaskJSON is the compact task identity used by graph-query envelopes." + }, "InitEnvelope": { "properties": { "schema_version": { @@ -1338,6 +1551,83 @@ ], "description": "Issue is a single frontmatter lint finding." }, + "LegacyDependencyJSON": { + "properties": { + "task_id": { + "type": "string" + }, + "task_slug": { + "type": "string" + }, + "path": { + "type": "string" + }, + "field": { + "type": "string" + }, + "references": { + "items": { + "$ref": "#/$defs/LegacyReferenceJSON" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "task_id", + "field", + "references" + ], + "description": "LegacyDependencyJSON is one legacy field occurrence reported by a graph query." + }, + "LegacyFieldClearJSON": { + "properties": { + "task_id": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "task_id", + "field" + ], + "description": "LegacyFieldClearJSON identifies one legacy field occurrence cleared by migration." + }, + "LegacyReferenceJSON": { + "properties": { + "value": { + "type": "string" + }, + "resolution": { + "type": "string" + }, + "candidate_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "prerequisite_id": { + "type": "string" + }, + "dependent_id": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value", + "resolution", + "candidate_ids" + ], + "description": "LegacyReferenceJSON records one legacy dependency resolution." + }, "LintEnvelope": { "properties": { "schema_version": { @@ -2057,6 +2347,121 @@ ], "description": "SummaryJSON is the reusable, versionless dashboard payload." }, + "TaskBlockerJSON": { + "properties": { + "task": { + "$ref": "#/$defs/GraphTaskJSON" + }, + "state": { + "$ref": "#/$defs/TaskGraphStateJSON" + }, + "reason": { + "type": "string" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "direct": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "task", + "state", + "reason", + "path", + "direct" + ], + "description": "TaskBlockerJSON is one direct/transitive blocker with explanatory path." + }, + "TaskBlockersEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "task": { + "$ref": "#/$defs/GraphTaskJSON" + }, + "state": { + "$ref": "#/$defs/TaskGraphStateJSON" + }, + "projection": { + "type": "string" + }, + "health": { + "type": "string" + }, + "blockers": { + "items": { + "$ref": "#/$defs/TaskBlockerJSON" + }, + "type": "array" + }, + "problems": { + "items": { + "$ref": "#/$defs/GraphProblemJSON" + }, + "type": "array" + }, + "legacy_dependencies": { + "items": { + "$ref": "#/$defs/LegacyDependencyJSON" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "task", + "state", + "projection", + "health", + "blockers", + "problems", + "legacy_dependencies" + ], + "description": "TaskBlockersEnvelope is `task blockers --json`." + }, + "TaskGraphStateJSON": { + "properties": { + "role": { + "type": "string" + }, + "gate": { + "type": "string" + }, + "soundly_completed": { + "type": "boolean" + }, + "eligible": { + "type": "boolean" + }, + "drained": { + "type": "boolean" + }, + "inconsistent": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "role", + "gate", + "soundly_completed", + "eligible", + "drained", + "inconsistent" + ], + "description": "TaskGraphStateJSON is the derived lifecycle/gate projection for one task." + }, "TaskInfoEnvelope": { "properties": { "schema_version": { @@ -2234,6 +2639,80 @@ ], "description": "TaskShowEnvelope is `task show --json`." }, + "TaskUnblockJSON": { + "properties": { + "task": { + "$ref": "#/$defs/GraphTaskJSON" + }, + "state": { + "$ref": "#/$defs/TaskGraphStateJSON" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "direct": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "task", + "state", + "path", + "direct" + ], + "description": "TaskUnblockJSON is one transitive downstream dependent and its current state." + }, + "TaskUnblocksEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "task": { + "$ref": "#/$defs/GraphTaskJSON" + }, + "state": { + "$ref": "#/$defs/TaskGraphStateJSON" + }, + "health": { + "type": "string" + }, + "unblocks": { + "items": { + "$ref": "#/$defs/TaskUnblockJSON" + }, + "type": "array" + }, + "problems": { + "items": { + "$ref": "#/$defs/GraphProblemJSON" + }, + "type": "array" + }, + "legacy_dependencies": { + "items": { + "$ref": "#/$defs/LegacyDependencyJSON" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "task", + "state", + "health", + "unblocks", + "problems", + "legacy_dependencies" + ], + "description": "TaskUnblocksEnvelope is `task unblocks --json`." + }, "TasksEnvelope": { "properties": { "schema_version": { @@ -2512,6 +2991,15 @@ "task_mutation": { "$ref": "#/$defs/TaskMutationEnvelope" }, + "dependency_mutation": { + "$ref": "#/$defs/DependencyMutationEnvelope" + }, + "task_blockers": { + "$ref": "#/$defs/TaskBlockersEnvelope" + }, + "task_unblocks": { + "$ref": "#/$defs/TaskUnblocksEnvelope" + }, "epic_mutation": { "$ref": "#/$defs/EpicMutationEnvelope" }, @@ -2619,6 +3107,9 @@ "acceptance", "path", "task_mutation", + "dependency_mutation", + "task_blockers", + "task_unblocks", "epic_mutation", "moves", "summary", @@ -2654,6 +3145,6 @@ ] } }, - "title": "tskflwctl --json output (schema_version 1.49)", + "title": "tskflwctl --json output (schema_version 1.51)", "description": "Each property of the root names a --json envelope and references its definition in $defs; validate a command's --json output against the matching definition." } diff --git a/internal/cli/testdata/golden/schema_task_json.golden b/internal/cli/testdata/golden/schema_task_json.golden index f48e478b..ecab9c23 100644 --- a/internal/cli/testdata/golden/schema_task_json.golden +++ b/internal/cli/testdata/golden/schema_task_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","depends_on is a sorted set of stable task IDs owned by the repository-global DAG. It is preservation-only until guarded `task depend add/remove` commands land: generic task creation, `task set`, `task edit`, and `lint --fix` cannot add, remove, or reinterpret it.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1.","an acceptance criterion is `- [x]` (met) or `- [ ]` (not met). A not-met criterion may say WHY with a trailing `· **deferred:** reason` — one of: deferred | n/a | tracked | wontfix. A reason is required for those, and a checked criterion takes no suffix.","never hand-edit the acceptance criteria — `task ac` owns them: --check/--uncheck \u003cn\u003e, --defer/--wontfix/--tracked/--na \u003cn\u003e --reason \u003cwhy\u003e for a state, and --add/--remove/--replace \u003cn\u003e to change which criteria exist."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} +{"schema_version":"1.51","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","depends_on is a sorted set of stable task IDs owned by the repository-global DAG. It is preservation-only until guarded `task depend add/remove` commands land: generic task creation, `task set`, `task edit`, and `lint --fix` cannot add, remove, or reinterpret it.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1.","an acceptance criterion is `- [x]` (met) or `- [ ]` (not met). A not-met criterion may say WHY with a trailing `· **deferred:** reason` — one of: deferred | n/a | tracked | wontfix. A reason is required for those, and a checked criterion takes no suffix.","never hand-edit the acceptance criteria — `task ac` owns them: --check/--uncheck \u003cn\u003e, --defer/--wontfix/--tracked/--na \u003cn\u003e --reason \u003cwhy\u003e for a state, and --add/--remove/--replace \u003cn\u003e to change which criteria exist."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} diff --git a/internal/cli/testdata/golden/status_all_json.golden b/internal/cli/testdata/golden/status_all_json.golden index 07faab42..8f658c79 100644 --- a/internal/cli/testdata/golden/status_all_json.golden +++ b/internal/cli/testdata/golden/status_all_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","spaces":[{"id":"planning","planning_id":"6fjangd7kvhz","selected_entry_point":"planning","entry_points":[{"id":"implementation","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"pointer","added":"2026-08-21","state":"ok","root":""},{"id":"planning","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"direct","added":"2026-08-21","state":"ok","root":""},{"id":"missing","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"unknown","added":"2026-08-21","state":"missing","detail":"not found at ","remedy":"`space forget missing`, then `space add \u003cnew-path\u003e --id missing`"}],"summary":{"counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0}}],"in_progress":[{"space":"planning","planning_id":"6fjangd7kvhz","task":{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}}]} +{"schema_version":"1.51","spaces":[{"id":"planning","planning_id":"6fjangd7kvhz","selected_entry_point":"planning","entry_points":[{"id":"implementation","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"pointer","added":"2026-08-21","state":"ok","root":""},{"id":"planning","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"direct","added":"2026-08-21","state":"ok","root":""},{"id":"missing","path":"","verify_id":"6fjangd7kvhz","planning_id":"6fjangd7kvhz","role":"unknown","added":"2026-08-21","state":"missing","detail":"not found at ","remedy":"`space forget missing`, then `space add \u003cnew-path\u003e --id missing`"}],"summary":{"counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0}}],"in_progress":[{"space":"planning","planning_id":"6fjangd7kvhz","task":{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}}]} diff --git a/internal/cli/testdata/golden/status_json.golden b/internal/cli/testdata/golden/status_json.golden index 9cfffc45..128e459f 100644 --- a/internal/cli/testdata/golden/status_json.golden +++ b/internal/cli/testdata/golden/status_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} +{"schema_version":"1.51","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} diff --git a/internal/cli/testdata/golden/task_acceptance_json.golden b/internal/cli/testdata/golden/task_acceptance_json.golden index b593da74..b36dff94 100644 --- a/internal/cli/testdata/golden/task_acceptance_json.golden +++ b/internal/cli/testdata/golden/task_acceptance_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","slug":"alpha-task","acceptance":[{"index":1,"checked":true,"text":"the first criterion is done"},{"index":2,"checked":false,"text":"the second criterion is not"}]} +{"schema_version":"1.51","slug":"alpha-task","acceptance":[{"index":1,"checked":true,"text":"the first criterion is done"},{"index":2,"checked":false,"text":"the second criterion is not"}]} diff --git a/internal/cli/testdata/golden/task_blockers_json.golden b/internal/cli/testdata/golden/task_blockers_json.golden new file mode 100644 index 00000000..37d83219 --- /dev/null +++ b/internal/cli/testdata/golden/task_blockers_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.51","task":{"task_id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic"},"state":{"role":"candidate","gate":"clear","soundly_completed":false,"eligible":true,"drained":false,"inconsistent":false},"projection":"frontier","health":"healthy","blockers":[],"problems":[],"legacy_dependencies":[]} diff --git a/internal/cli/testdata/golden/task_info_json.golden b/internal/cli/testdata/golden/task_info_json.golden index a810efc4..3c6c3468 100644 --- a/internal/cli/testdata/golden/task_info_json.golden +++ b/internal/cli/testdata/golden/task_info_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","task_info":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","path":"/tasks/6fjangd7kvh0-alpha-task.md","ac":{"checked":1,"total":2}}} +{"schema_version":"1.51","task_info":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","path":"/tasks/6fjangd7kvh0-alpha-task.md","ac":{"checked":1,"total":2}}} diff --git a/internal/cli/testdata/golden/task_list_json.golden b/internal/cli/testdata/golden/task_list_json.golden index 6e8c0554..33dc54f5 100644 --- a/internal/cli/testdata/golden/task_list_json.golden +++ b/internal/cli/testdata/golden/task_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} +{"schema_version":"1.51","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} diff --git a/internal/cli/testdata/golden/task_list_unblocked_json.golden b/internal/cli/testdata/golden/task_list_unblocked_json.golden new file mode 100644 index 00000000..9b7407cd --- /dev/null +++ b/internal/cli/testdata/golden/task_list_unblocked_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.51","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]}]} diff --git a/internal/cli/testdata/golden/task_path_json.golden b/internal/cli/testdata/golden/task_path_json.golden index 034adc4b..9f4877e8 100644 --- a/internal/cli/testdata/golden/task_path_json.golden +++ b/internal/cli/testdata/golden/task_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","path":"/tasks/6fjangd7kvh0-alpha-task.md"} +{"schema_version":"1.51","path":"/tasks/6fjangd7kvh0-alpha-task.md"} diff --git a/internal/cli/testdata/golden/task_show_json.golden b/internal/cli/testdata/golden/task_show_json.golden index abb8848a..62dad4cd 100644 --- a/internal/cli/testdata/golden/task_show_json.golden +++ b/internal/cli/testdata/golden/task_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n\n## Acceptance criteria\n\n- [x] the first criterion is done\n- [ ] the second criterion is not\n"} +{"schema_version":"1.51","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"],"depends_on":["6fjangd7kvh2"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n\n## Acceptance criteria\n\n- [x] the first criterion is done\n- [ ] the second criterion is not\n"} diff --git a/internal/cli/testdata/golden/task_unblocks_json.golden b/internal/cli/testdata/golden/task_unblocks_json.golden new file mode 100644 index 00000000..20b46db1 --- /dev/null +++ b/internal/cli/testdata/golden/task_unblocks_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.51","task":{"task_id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic"},"state":{"role":"nominally-complete","gate":"clear","soundly_completed":true,"eligible":false,"drained":true,"inconsistent":false},"health":"healthy","unblocks":[{"task":{"task_id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic"},"state":{"role":"candidate","gate":"clear","soundly_completed":false,"eligible":true,"drained":false,"inconsistent":false},"path":["6fjangd7kvh2","6fjangd7kvh0"],"direct":true}],"problems":[],"legacy_dependencies":[]} diff --git a/internal/cli/testdata/golden/template_list_json.golden b/internal/cli/testdata/golden/template_list_json.golden index 51e56dd2..366af78e 100644 --- a/internal/cli/testdata/golden/template_list_json.golden +++ b/internal/cli/testdata/golden/template_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},{"kind":"research","name":"default","description":"Standard research scaffold: question, findings, recommendation."}]} +{"schema_version":"1.51","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},{"kind":"research","name":"default","description":"Standard research scaffold: question, findings, recommendation."}]} diff --git a/internal/cli/testdata/golden/template_show_security_json.golden b/internal/cli/testdata/golden/template_show_security_json.golden index bf150a5b..6800cd8e 100644 --- a/internal/cli/testdata/golden/template_show_security_json.golden +++ b/internal/cli/testdata/golden/template_show_security_json.golden @@ -1 +1 @@ -{"schema_version":"1.49","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} +{"schema_version":"1.51","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} diff --git a/internal/wire/dependency.go b/internal/wire/dependency.go new file mode 100644 index 00000000..2fb940ad --- /dev/null +++ b/internal/wire/dependency.go @@ -0,0 +1,230 @@ +package wire + +import ( + "github.com/andy-esch/taskflow/internal/core" + "github.com/andy-esch/taskflow/internal/domain" +) + +// GraphTaskJSON is the compact task identity used by graph-query envelopes. +// TaskID remains present even when the underlying task is unreadable. +type GraphTaskJSON struct { + TaskID string `json:"task_id" jsonschema:"description=canonical stable task identifier"` + Slug string `json:"slug,omitempty" jsonschema:"description=human task slug when the task is readable"` + Status string `json:"status,omitempty" jsonschema:"description=persisted lifecycle status when readable"` + Epic string `json:"epic,omitempty" jsonschema:"description=epic identifier when readable"` +} + +func toGraphTaskJSON(taskID string, task domain.Task) GraphTaskJSON { + return GraphTaskJSON{TaskID: taskID, Slug: task.Slug, Status: string(task.Status), Epic: task.Epic} +} + +// TaskGraphStateJSON is the derived lifecycle/gate projection for one task. +type TaskGraphStateJSON struct { + Role string `json:"role"` + Gate string `json:"gate"` + SoundlyCompleted bool `json:"soundly_completed"` + Eligible bool `json:"eligible"` + Drained bool `json:"drained"` + Inconsistent bool `json:"inconsistent"` +} + +func toTaskGraphStateJSON(state core.TaskGraphState) TaskGraphStateJSON { + return TaskGraphStateJSON{ + Role: string(state.Role), Gate: string(state.Gate), SoundlyCompleted: state.SoundlyCompleted, + Eligible: state.Eligible, Drained: state.Drained, Inconsistent: state.Inconsistent, + } +} + +// GraphProblemJSON is taskflow-owned repository-graph diagnostic data. +type GraphProblemJSON struct { + Code string `json:"code"` + TaskID string `json:"task_id,omitempty"` + RelatedTaskID string `json:"related_task_id,omitempty"` + Field string `json:"field,omitempty"` + Path string `json:"path,omitempty"` + Message string `json:"message"` + Cycle []string `json:"cycle,omitempty"` +} + +func toGraphProblemsJSON(problems []core.GraphProblem) []GraphProblemJSON { + out := make([]GraphProblemJSON, 0, len(problems)) + for _, problem := range problems { + out = append(out, GraphProblemJSON{ + Code: string(problem.Code), TaskID: problem.TaskID, RelatedTaskID: problem.RelatedTaskID, + Field: problem.Field, Path: problem.Path, Message: problem.Message, + Cycle: append([]string(nil), problem.Cycle...), + }) + } + return out +} + +// LegacyReferenceJSON records one legacy dependency resolution. +type LegacyReferenceJSON struct { + Value string `json:"value"` + Resolution string `json:"resolution"` + CandidateIDs []string `json:"candidate_ids"` + PrerequisiteID string `json:"prerequisite_id,omitempty"` + DependentID string `json:"dependent_id,omitempty"` +} + +// LegacyDependencyJSON is one legacy field occurrence reported by a graph query. +type LegacyDependencyJSON struct { + TaskID string `json:"task_id"` + TaskSlug string `json:"task_slug,omitempty"` + Path string `json:"path,omitempty"` + Field string `json:"field"` + References []LegacyReferenceJSON `json:"references"` +} + +func toLegacyDependenciesJSON(diagnostics []core.LegacyDependencyDiagnostic) []LegacyDependencyJSON { + out := make([]LegacyDependencyJSON, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + item := LegacyDependencyJSON{ + TaskID: diagnostic.TaskID, TaskSlug: diagnostic.TaskSlug, Path: diagnostic.TaskPath, + Field: diagnostic.Field, References: make([]LegacyReferenceJSON, 0, len(diagnostic.References)), + } + for _, ref := range diagnostic.References { + item.References = append(item.References, LegacyReferenceJSON{ + Value: ref.Value, Resolution: string(ref.Resolution), CandidateIDs: append([]string{}, ref.CandidateIDs...), + PrerequisiteID: ref.Edge.From, DependentID: ref.Edge.To, + }) + } + out = append(out, item) + } + return out +} + +// TaskBlockerJSON is one direct/transitive blocker with explanatory path. +type TaskBlockerJSON struct { + Task GraphTaskJSON `json:"task"` + State TaskGraphStateJSON `json:"state"` + Reason string `json:"reason"` + Path []string `json:"path"` + Direct bool `json:"direct"` +} + +// TaskBlockersEnvelope is `task blockers --json`. +type TaskBlockersEnvelope struct { + SchemaVersion string `json:"schema_version"` + Task GraphTaskJSON `json:"task"` + State TaskGraphStateJSON `json:"state"` + Projection string `json:"projection"` + Health string `json:"health"` + Blockers []TaskBlockerJSON `json:"blockers"` + Problems []GraphProblemJSON `json:"problems"` + Legacy []LegacyDependencyJSON `json:"legacy_dependencies"` +} + +// ToTaskBlockersEnvelope maps the core diagnostic query into its wire contract. +func ToTaskBlockersEnvelope(result core.TaskBlockersResult) TaskBlockersEnvelope { + envelope := TaskBlockersEnvelope{ + SchemaVersion: SchemaVersion, Task: toGraphTaskJSON(result.TaskID, result.Task), + State: toTaskGraphStateJSON(result.State), Projection: result.Projection, Health: string(result.Health), + Blockers: make([]TaskBlockerJSON, 0, len(result.Blockers)), + Problems: toGraphProblemsJSON(result.Problems), Legacy: toLegacyDependenciesJSON(result.Legacy), + } + for _, blocker := range result.Blockers { + envelope.Blockers = append(envelope.Blockers, TaskBlockerJSON{ + Task: toGraphTaskJSON(blocker.Blocker.TaskID, blocker.Task), State: toTaskGraphStateJSON(blocker.State), + Reason: string(blocker.Blocker.Reason), Path: append([]string(nil), blocker.Blocker.Path...), Direct: blocker.Blocker.Direct, + }) + } + return envelope +} + +// TaskUnblockJSON is one transitive downstream dependent and its current state. +type TaskUnblockJSON struct { + Task GraphTaskJSON `json:"task"` + State TaskGraphStateJSON `json:"state"` + Path []string `json:"path"` + Direct bool `json:"direct"` +} + +// TaskUnblocksEnvelope is `task unblocks --json`. +type TaskUnblocksEnvelope struct { + SchemaVersion string `json:"schema_version"` + Task GraphTaskJSON `json:"task"` + State TaskGraphStateJSON `json:"state"` + Health string `json:"health"` + Unblocks []TaskUnblockJSON `json:"unblocks"` + Problems []GraphProblemJSON `json:"problems"` + Legacy []LegacyDependencyJSON `json:"legacy_dependencies"` +} + +// ToTaskUnblocksEnvelope maps the downstream-impact query into its wire contract. +func ToTaskUnblocksEnvelope(result core.TaskUnblocksResult) TaskUnblocksEnvelope { + envelope := TaskUnblocksEnvelope{ + SchemaVersion: SchemaVersion, Task: toGraphTaskJSON(result.TaskID, result.Task), + State: toTaskGraphStateJSON(result.State), Health: string(result.Health), Unblocks: make([]TaskUnblockJSON, 0, len(result.Unblocks)), + Problems: toGraphProblemsJSON(result.Problems), Legacy: toLegacyDependenciesJSON(result.Legacy), + } + for _, dependent := range result.Unblocks { + envelope.Unblocks = append(envelope.Unblocks, TaskUnblockJSON{ + Task: toGraphTaskJSON(dependent.Impact.TaskID, dependent.Task), State: toTaskGraphStateJSON(dependent.State), + Path: append([]string(nil), dependent.Impact.Path...), Direct: dependent.Impact.Direct, + }) + } + return envelope +} + +// DependencyEdgeOutcomeJSON is one canonical edge result in a mutation receipt. +type DependencyEdgeOutcomeJSON struct { + DependentID string `json:"dependent_id"` + PrerequisiteID string `json:"prerequisite_id"` + Action string `json:"action"` + Outcome string `json:"outcome"` +} + +// LegacyFieldClearJSON identifies one legacy field occurrence cleared by migration. +type LegacyFieldClearJSON struct { + TaskID string `json:"task_id"` + Field string `json:"field"` +} + +// DependencyMutationJSON is the reusable dependency-mutation receipt payload. +type DependencyMutationJSON struct { + Operation string `json:"operation"` + Changed bool `json:"changed"` + DryRun bool `json:"dry_run"` + Edges []DependencyEdgeOutcomeJSON `json:"edges"` + ClearedLegacyFields []LegacyFieldClearJSON `json:"cleared_legacy_fields"` + PlannedTaskIDs []string `json:"planned_task_ids"` + AppliedTaskIDs []string `json:"applied_task_ids"` + RemainingTaskIDs []string `json:"remaining_task_ids"` + Workspace WorkspaceJSON `json:"workspace"` +} + +// ToDependencyMutationJSON maps a core receipt and adapter-owned workspace identity. +func ToDependencyMutationJSON(receipt core.DependencyMutationReceipt, workspace WorkspaceJSON) DependencyMutationJSON { + payload := DependencyMutationJSON{ + Operation: string(receipt.Operation), Changed: receipt.Changed, DryRun: receipt.DryRun, + Edges: make([]DependencyEdgeOutcomeJSON, 0, len(receipt.Edges)), + ClearedLegacyFields: make([]LegacyFieldClearJSON, 0, len(receipt.ClearedLegacyFields)), + PlannedTaskIDs: append([]string{}, receipt.PlannedTaskIDs...), + AppliedTaskIDs: append([]string{}, receipt.AppliedTaskIDs...), + RemainingTaskIDs: append([]string{}, receipt.RemainingTaskIDs...), Workspace: workspace, + } + for _, edge := range receipt.Edges { + payload.Edges = append(payload.Edges, DependencyEdgeOutcomeJSON{ + DependentID: edge.DependentID, PrerequisiteID: edge.PrerequisiteID, + Action: string(edge.Action), Outcome: edge.Outcome, + }) + } + for _, clear := range receipt.ClearedLegacyFields { + payload.ClearedLegacyFields = append(payload.ClearedLegacyFields, LegacyFieldClearJSON{TaskID: clear.TaskID, Field: clear.Field}) + } + return payload +} + +// DependencyMutationEnvelope is the successful `task depend` machine receipt. +type DependencyMutationEnvelope struct { + SchemaVersion string `json:"schema_version"` + DependencyMutationJSON +} + +// ToDependencyMutationEnvelope wraps a dependency mutation receipt. +func ToDependencyMutationEnvelope(receipt core.DependencyMutationReceipt, workspace WorkspaceJSON) DependencyMutationEnvelope { + return DependencyMutationEnvelope{ + SchemaVersion: SchemaVersion, DependencyMutationJSON: ToDependencyMutationJSON(receipt, workspace), + } +} diff --git a/internal/wire/envelopes.go b/internal/wire/envelopes.go index 1ea51c0a..b33df025 100644 --- a/internal/wire/envelopes.go +++ b/internal/wire/envelopes.go @@ -987,8 +987,9 @@ func ToTemplateShowEnvelope(info TemplateInfo, body string) TemplateShowEnvelope // ErrorItem is the error body inside ErrorEnvelope. type ErrorItem struct { - Code string `json:"code"` - Message string `json:"message"` + Code string `json:"code"` + Message string `json:"message"` + DependencyMutation *DependencyMutationJSON `json:"dependency_mutation,omitempty"` } // ErrorEnvelope is the failure payload emitted under --json (see cli.WriteError). @@ -1043,45 +1044,48 @@ type DoctorSpaceProblem struct { // jsonEnvelopes registers every envelope so a single Reflect pulls them all (and // their shared types) into one schema document's $defs. type jsonEnvelopes struct { - Tasks TasksEnvelope `json:"tasks"` - Board BoardEnvelope `json:"board"` - TaskShow TaskShowEnvelope `json:"task_show"` - TaskInfo TaskInfoEnvelope `json:"task_info"` - Acceptance AcceptanceEnvelope `json:"acceptance"` - Path PathEnvelope `json:"path"` - TaskMutation TaskMutationEnvelope `json:"task_mutation"` - EpicMutation EpicMutationEnvelope `json:"epic_mutation"` - Moves MovesEnvelope `json:"moves"` - Summary SummaryEnvelope `json:"summary"` - StatusAll StatusAllEnvelope `json:"status_all"` - Version VersionEnvelope `json:"version"` - Workspace WorkspaceEnvelope `json:"workspace"` - Config ConfigEnvelope `json:"config"` - ConfigMigrate ConfigMigrationEnvelope `json:"config_migration"` - Spaces SpacesEnvelope `json:"spaces"` - SpaceMutation SpaceMutationEnvelope `json:"space_mutation"` - Created CreatedEnvelope `json:"created"` - Epics EpicsEnvelope `json:"epics"` - EpicShow EpicShowEnvelope `json:"epic_show"` - Audits AuditsEnvelope `json:"audits"` - AuditShow AuditShowEnvelope `json:"audit_show"` - AuditInfo AuditInfoEnvelope `json:"audit_info"` - AuditMutation AuditMutationEnvelope `json:"audit_mutation"` - Findings FindingsEnvelope `json:"findings"` - ResearchList ResearchListEnvelope `json:"research_list"` - ResearchShow ResearchShowEnvelope `json:"research_show"` - ResearchMut ResearchMutationEnvelope `json:"research_mutation"` - Fix FixEnvelope `json:"fix"` - Lint LintEnvelope `json:"lint"` - Init InitEnvelope `json:"init"` - Doctor DoctorEnvelope `json:"doctor"` - Schema SchemaEnvelope `json:"schema"` - SchemaKind SchemaKindEnvelope `json:"schema_kind"` - Templates TemplatesEnvelope `json:"templates"` - TemplateShow TemplateShowEnvelope `json:"template_show"` - Themes ThemesEnvelope `json:"themes"` - ThemePreview ThemePreviewEnvelope `json:"theme_preview"` - Error ErrorEnvelope `json:"error"` + Tasks TasksEnvelope `json:"tasks"` + Board BoardEnvelope `json:"board"` + TaskShow TaskShowEnvelope `json:"task_show"` + TaskInfo TaskInfoEnvelope `json:"task_info"` + Acceptance AcceptanceEnvelope `json:"acceptance"` + Path PathEnvelope `json:"path"` + TaskMutation TaskMutationEnvelope `json:"task_mutation"` + DependencyMut DependencyMutationEnvelope `json:"dependency_mutation"` + TaskBlockers TaskBlockersEnvelope `json:"task_blockers"` + TaskUnblocks TaskUnblocksEnvelope `json:"task_unblocks"` + EpicMutation EpicMutationEnvelope `json:"epic_mutation"` + Moves MovesEnvelope `json:"moves"` + Summary SummaryEnvelope `json:"summary"` + StatusAll StatusAllEnvelope `json:"status_all"` + Version VersionEnvelope `json:"version"` + Workspace WorkspaceEnvelope `json:"workspace"` + Config ConfigEnvelope `json:"config"` + ConfigMigrate ConfigMigrationEnvelope `json:"config_migration"` + Spaces SpacesEnvelope `json:"spaces"` + SpaceMutation SpaceMutationEnvelope `json:"space_mutation"` + Created CreatedEnvelope `json:"created"` + Epics EpicsEnvelope `json:"epics"` + EpicShow EpicShowEnvelope `json:"epic_show"` + Audits AuditsEnvelope `json:"audits"` + AuditShow AuditShowEnvelope `json:"audit_show"` + AuditInfo AuditInfoEnvelope `json:"audit_info"` + AuditMutation AuditMutationEnvelope `json:"audit_mutation"` + Findings FindingsEnvelope `json:"findings"` + ResearchList ResearchListEnvelope `json:"research_list"` + ResearchShow ResearchShowEnvelope `json:"research_show"` + ResearchMut ResearchMutationEnvelope `json:"research_mutation"` + Fix FixEnvelope `json:"fix"` + Lint LintEnvelope `json:"lint"` + Init InitEnvelope `json:"init"` + Doctor DoctorEnvelope `json:"doctor"` + Schema SchemaEnvelope `json:"schema"` + SchemaKind SchemaKindEnvelope `json:"schema_kind"` + Templates TemplatesEnvelope `json:"templates"` + TemplateShow TemplateShowEnvelope `json:"template_show"` + Themes ThemesEnvelope `json:"themes"` + ThemePreview ThemePreviewEnvelope `json:"theme_preview"` + Error ErrorEnvelope `json:"error"` } // Envelopes returns the reflect type of the registry so a coverage test can diff --git a/internal/wire/envelopes_test.go b/internal/wire/envelopes_test.go index 1dd1acd1..205a4b6b 100644 --- a/internal/wire/envelopes_test.go +++ b/internal/wire/envelopes_test.go @@ -67,6 +67,43 @@ func TestJSONSchema_ValidatesRealOutput(t *testing.T) { {"TaskMutationEnvelope", func(w io.Writer) error { return emit(w, ToTaskMutationEnvelope(task, "# new body", true, WorkspaceJSON{})) }}, + {"DependencyMutationEnvelope", func(w io.Writer) error { + return emit(w, ToDependencyMutationEnvelope(core.DependencyMutationReceipt{ + Operation: core.DependencyAdd, Changed: true, DryRun: true, + Edges: []core.DependencyEdgeOutcome{{ + DependentID: "6g0000000002", PrerequisiteID: "6g0000000001", + Action: core.DependencyAdd, Outcome: "added", + }}, + PlannedTaskIDs: []string{"6g0000000002"}, + }, WorkspaceJSON{PlanningRoot: "/repo/planning", Source: WorkspaceSourceConfig})) + }}, + {"TaskBlockersEnvelope", func(w io.Writer) error { + return emit(w, ToTaskBlockersEnvelope(core.TaskBlockersResult{ + TaskID: "6g0000000002", Task: task, + State: core.TaskGraphState{TaskID: "6g0000000002", Role: core.RoleInFlight, Gate: core.GateBroken, Inconsistent: true}, + Projection: "frontier", Health: core.GraphBroken, + Problems: []core.GraphProblem{{Code: core.ProblemLegacyMissing, TaskID: "6g0000000002", Field: "blocked_by", Message: "missing legacy reference"}}, + Legacy: []core.LegacyDependencyDiagnostic{{ + TaskID: "6g0000000002", TaskSlug: "alpha", Field: "blocked_by", + References: []core.LegacyReference{{Value: "gone", Resolution: core.LegacyMissing}}, + }}, + Blockers: []core.TaskBlockerDetail{{ + Blocker: core.Blocker{TaskID: "6g0000000001", Reason: core.BlockerNotStarted, Path: []string{"6g0000000002", "6g0000000001"}, Direct: true}, + Task: beta, State: core.TaskGraphState{TaskID: "6g0000000001", Role: core.RoleCandidate, Gate: core.GateClear, Eligible: true}, + }}, + })) + }}, + {"TaskUnblocksEnvelope", func(w io.Writer) error { + return emit(w, ToTaskUnblocksEnvelope(core.TaskUnblocksResult{ + TaskID: "6g0000000001", Task: beta, + State: core.TaskGraphState{TaskID: "6g0000000001", Role: core.RoleCandidate, Gate: core.GateClear, Eligible: true}, + Health: core.GraphHealthy, + Unblocks: []core.TaskDependentDetail{{ + Impact: core.DependentImpact{TaskID: "6g0000000002", Path: []string{"6g0000000001", "6g0000000002"}, Direct: true}, + Task: task, State: core.TaskGraphState{TaskID: "6g0000000002", Role: core.RoleInFlight, Gate: core.GateClear}, + }}, + })) + }}, {"EpicMutationEnvelope", func(w io.Writer) error { return emit(w, ToEpicMutationEnvelope(epic, true, WorkspaceJSON{})) }}, {"CreatedEnvelope", func(w io.Writer) error { return emit(w, ToCreatedEnvelope("task", "6fsa428vc2mm", "alpha", "ready-to-start", "tasks/6fsa428vc2mm-alpha.md", false, WorkspaceJSON{})) diff --git a/internal/wire/schema_comments.json b/internal/wire/schema_comments.json index 310b1056..910ca930 100644 --- a/internal/wire/schema_comments.json +++ b/internal/wire/schema_comments.json @@ -68,6 +68,7 @@ "github.com/andy-esch/taskflow/internal/domain.Task.FilenameID": "FilenameID is that same id as parsed from the flat filename's leading field\n(set by the store via splitFlatName). It is the canonical key resolveID/CAS\nmatch on; the frontmatter `id:` above is a co-located copy that must equal it,\nand lint flags any drift (IDDriftIssue). Derived, not frontmatter.", "github.com/andy-esch/taskflow/internal/domain.Task.ID": "ID is the stable 12-char identifier (ADR-0003 §3): it leads the flat filename\n(tasks/\u003cid\u003e-\u003cslug\u003e.md) and is the primary resolution key.", "github.com/andy-esch/taskflow/internal/domain.Task.LegacyBlockedBy": "These fields are read-only legacy vocabulary. Keeping them on the typed record\nlets the strict snapshot resolve and diagnose the live slug references without\ntreating them as canonical edges or silently dropping them during analysis. The\nguarded dependency-migration slice removes them later.", + "github.com/andy-esch/taskflow/internal/domain.Task.LegacyDependencyFields": "LegacyDependencyFields preserves field presence separately from values so an\nexplicitly empty legacy key remains diagnosable and migratable. Values are\nthe canonical field names and are populated by the store parser.", "github.com/andy-esch/taskflow/internal/domain.Task.RevisitAt": "optional \"snooze until\" date for a deferred task (set by `task defer`)", "github.com/andy-esch/taskflow/internal/domain.Task.SourceVersion": "SourceVersion is the store-internal hash of the exact bytes that produced this\nrecord. TaskGraph retains it for whole-snapshot CAS but clears it from Task()\nprojections, so planners never receive persistence tokens.", "github.com/andy-esch/taskflow/internal/domain.Task.StartedAt": "stamped when a task enters in-progress (incl. `new --start`)", @@ -107,6 +108,9 @@ "github.com/andy-esch/taskflow/internal/wire.CreatedItem.Slug": "Slug is the HUMAN, mutable name — it changes when the entity is renamed, so it\nis for display and for typing at a prompt, never for saving as a reference.", "github.com/andy-esch/taskflow/internal/wire.CriterionJSON": "CriterionJSON is one acceptance-criteria checkbox for `task ac --list --json` — the list an agent then flips by index with `task ac --check/--uncheck`.", "github.com/andy-esch/taskflow/internal/wire.CriterionJSON.State": "State and Reason carry the disposition beyond the checkbox. Omitted when the\ncriterion is a plain met/not-met, so a body written before the vocabulary existed\nserialises exactly as it did. Without these an agent reading `task ac --list --json`\ncould see an unchecked box but not whether it was still to do, deferred, abandoned,\nor no longer applicable — the ambiguity the vocabulary exists to remove, reintroduced\nat the machine boundary. Schema 1.46.", + "github.com/andy-esch/taskflow/internal/wire.DependencyEdgeOutcomeJSON": "DependencyEdgeOutcomeJSON is one canonical edge result in a mutation receipt.", + "github.com/andy-esch/taskflow/internal/wire.DependencyMutationEnvelope": "DependencyMutationEnvelope is the successful `task depend` machine receipt.", + "github.com/andy-esch/taskflow/internal/wire.DependencyMutationJSON": "DependencyMutationJSON is the reusable dependency-mutation receipt payload.", "github.com/andy-esch/taskflow/internal/wire.DoctorEnvelope": "DoctorEnvelope is `doctor --json`: the repo-local linkback audit plus the home space registry audit.", "github.com/andy-esch/taskflow/internal/wire.DoctorProblem": "DoctorProblem is one linkback inconsistency: the offending repo + a message.", "github.com/andy-esch/taskflow/internal/wire.DoctorRegistry": "DoctorRegistry is the home-registry section of doctor output.", @@ -128,9 +132,14 @@ "github.com/andy-esch/taskflow/internal/wire.FindingsTallyJSON": "FindingsTallyJSON is an audit's finding disposition tally (the `findings` field of `audit info`) — the audit analogue of a task's acceptance-criteria tally.", "github.com/andy-esch/taskflow/internal/wire.FixEnvelope": "FixEnvelope is `lint --fix --json`.", "github.com/andy-esch/taskflow/internal/wire.FixEnvelope.Workspace": "Workspace proves WHICH planning tree this receipt describes — see WorkspaceJSON.", + "github.com/andy-esch/taskflow/internal/wire.GraphProblemJSON": "GraphProblemJSON is taskflow-owned repository-graph diagnostic data.", + "github.com/andy-esch/taskflow/internal/wire.GraphTaskJSON": "GraphTaskJSON is the compact task identity used by graph-query envelopes.", "github.com/andy-esch/taskflow/internal/wire.InitEnvelope": "InitEnvelope is `init --json`.", "github.com/andy-esch/taskflow/internal/wire.InitRegistrationJSON": "InitRegistrationJSON is the best-effort home-registry receipt attached when init creates a fresh config.", "github.com/andy-esch/taskflow/internal/wire.KindSchema": "KindSchema is the per-kind authoring guidance (`tskflwctl schema \u003ckind\u003e`): how to compose a well-formed document of that kind.", + "github.com/andy-esch/taskflow/internal/wire.LegacyDependencyJSON": "LegacyDependencyJSON is one legacy field occurrence reported by a graph query.", + "github.com/andy-esch/taskflow/internal/wire.LegacyFieldClearJSON": "LegacyFieldClearJSON identifies one legacy field occurrence cleared by migration.", + "github.com/andy-esch/taskflow/internal/wire.LegacyReferenceJSON": "LegacyReferenceJSON records one legacy dependency resolution.", "github.com/andy-esch/taskflow/internal/wire.LintEnvelope": "LintEnvelope is `lint --json` and `audit lint --json` (the same per-entity slug+issues shape backs both).", "github.com/andy-esch/taskflow/internal/wire.LintTaskJSON": "LintTaskJSON is one entity's lint result (slug + field issues), the shape the `lint` / `audit lint` / `fix --remaining` envelopes carry per entity.", "github.com/andy-esch/taskflow/internal/wire.MoveResult": "MoveResult is the per-item outcome of a transition.", @@ -162,6 +171,9 @@ "github.com/andy-esch/taskflow/internal/wire.StatusCountJSON": "StatusCountJSON is one status bucket and its task count.", "github.com/andy-esch/taskflow/internal/wire.SummaryEnvelope": "SummaryEnvelope is `status --json`: the shared dashboard payload with the one top-level schema version required of every machine envelope.", "github.com/andy-esch/taskflow/internal/wire.SummaryJSON": "SummaryJSON is the reusable, versionless dashboard payload.", + "github.com/andy-esch/taskflow/internal/wire.TaskBlockerJSON": "TaskBlockerJSON is one direct/transitive blocker with explanatory path.", + "github.com/andy-esch/taskflow/internal/wire.TaskBlockersEnvelope": "TaskBlockersEnvelope is `task blockers --json`.", + "github.com/andy-esch/taskflow/internal/wire.TaskGraphStateJSON": "TaskGraphStateJSON is the derived lifecycle/gate projection for one task.", "github.com/andy-esch/taskflow/internal/wire.TaskInfoEnvelope": "TaskInfoEnvelope wraps `task info --json` — the token-cheap task metadata read (file path + triage fields + acceptance tally, no body).", "github.com/andy-esch/taskflow/internal/wire.TaskInfoJSON": "TaskInfoJSON is the token-cheap metadata read for a task (`task info`): where the file lives plus the fields an agent triages on and the acceptance-criteria tally, WITHOUT the body — the machine counterpart to `task path` that avoids the full `task show` payload.", "github.com/andy-esch/taskflow/internal/wire.TaskJSON": "TaskJSON is the wire shape of a task inside the --json envelopes.", @@ -169,6 +181,8 @@ "github.com/andy-esch/taskflow/internal/wire.TaskMutationEnvelope": "TaskMutationEnvelope is `task set` / `task append` / `task set --body` under --json: the reloaded task, dry_run, and (for the body commands) the resulting body.", "github.com/andy-esch/taskflow/internal/wire.TaskMutationEnvelope.Workspace": "Workspace proves WHICH planning tree this receipt describes — see WorkspaceJSON.", "github.com/andy-esch/taskflow/internal/wire.TaskShowEnvelope": "TaskShowEnvelope is `task show --json`.", + "github.com/andy-esch/taskflow/internal/wire.TaskUnblockJSON": "TaskUnblockJSON is one transitive downstream dependent and its current state.", + "github.com/andy-esch/taskflow/internal/wire.TaskUnblocksEnvelope": "TaskUnblocksEnvelope is `task unblocks --json`.", "github.com/andy-esch/taskflow/internal/wire.TasksEnvelope": "TasksEnvelope is `task list --json`.", "github.com/andy-esch/taskflow/internal/wire.TemplateInfo": "TemplateInfo is one body template's listable metadata (kind/name/description), populated by the cli for `template list`/`show`.", "github.com/andy-esch/taskflow/internal/wire.TemplateShowEnvelope": "TemplateShowEnvelope is `template show --json` (a template's metadata + body).", diff --git a/internal/wire/wire.go b/internal/wire/wire.go index 0195478b..32bc2bd6 100644 --- a/internal/wire/wire.go +++ b/internal/wire/wire.go @@ -170,6 +170,13 @@ import ( // point selected for reading, and a combined space-badged in-progress working set. The // envelope owns one top-level schema_version; nested summaries reuse the versionless // SummaryJSON payload rather than pretending to be independent envelopes. +// 1.51: dependency blocker/downstream query envelopes carry the queried task's +// derived state, so eligibility is explicit and never inferred from an empty list. +// 1.50: guarded dependency operations add `dependency_mutation` receipts, structured +// partial-failure details on the error envelope, and the `task_blockers` / +// `task_unblocks` diagnostic envelopes. Graph queries carry health, taskflow-owned +// problems, legacy diagnostics, stable reason/path data, and derived task state. +// // 1.49: task payloads carry `depends_on`, the sorted stable IDs of repository-global // prerequisites declared by that task. Additive and omitted for tasks without edges. // The task field/schema contract also recognizes the persisted list while generic @@ -208,7 +215,7 @@ import ( // 1.43: fresh `init --json` receipts may include `registration`, describing the // best-effort machine-local space registration (including preview vs applied and whether // the physical checkout was already registered). -const SchemaVersion = "1.49" +const SchemaVersion = "1.51" // EncodeJSON writes the payload as compact (un-indented) JSON with a single // trailing newline. Machine output: pretty-printing is pure token cost for a From b99265b0baaf7b21503862e184672f3cb69d0d4b Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Fri, 28 Aug 2026 08:02:04 -0400 Subject: [PATCH 3/3] docs(planning): close guarded dependency slice Amend ADR-0006 with the mutation/query contract, migrate live legacy edges to canonical IDs, persist the Threads dependency sequence, and record readiness plus implementation audit dispositions. Complete the shipped slice while tracking guarded repair and post-mutation state follow-ups. --- .../adrs/0006-adopt-threads-as-task-dags.md | 37 + ...-dependency-mutations-and-graph-queries.md | 235 +++++++ ...raph-queries-implementation-antigravity.md | 203 ++++++ ...and-graph-queries-implementation-claude.md | 658 ++++++++++++++++++ ...e-coherent-palette-across-every-surface.md | 4 +- ...eme-config-table-and-selection-plumbing.md | 4 +- ...active-picker-theme-through-the-palette.md | 4 +- ...kj-route-tui-chrome-through-the-palette.md | 4 +- ...mands-glamour-polish-and-a-second-theme.md | 4 +- ...nd-the-cli-ansi-map-through-the-palette.md | 4 +- ...-dependency-mutations-and-graph-queries.md | 122 ++-- ...ligibility-across-every-task-start-path.md | 3 + ...-entity-lifecycle-and-graph-projections.md | 1 + ...tasks-into-threads-with-resumable-apply.md | 1 + ...nerate-deterministic-thread-graph-views.md | 2 + ...-usage-informed-thread-views-to-the-tui.md | 1 + ...epair-path-for-broken-dependency-graphs.md | 49 ++ 17 files changed, 1284 insertions(+), 52 deletions(-) create mode 100644 planning/audits/6g4aj7v60syg-2026-08-27-ship-guarded-dependency-mutations-and-graph-queries.md create mode 100644 planning/audits/6g4g3wyrr4ns-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-antigravity.md create mode 100644 planning/audits/6g4g61bsbzq3-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-claude.md create mode 100644 planning/tasks/6g4g8gatbnrs-add-a-guarded-repair-path-for-broken-dependency-graphs.md diff --git a/planning/adrs/0006-adopt-threads-as-task-dags.md b/planning/adrs/0006-adopt-threads-as-task-dags.md index 9a2e3fee..c3e8da0b 100644 --- a/planning/adrs/0006-adopt-threads-as-task-dags.md +++ b/planning/adrs/0006-adopt-threads-as-task-dags.md @@ -766,6 +766,43 @@ contention, and platform contracts. The following clarifications supersede confl simple. Before the bulk-linking slice is released, benchmark realistic repository/manifest sizes and replace it with incremental validation if lock-held latency is operationally significant. +### 2026-08-27: Dependency-operation command and recovery contracts + +The focused [dependency-operations readiness audit](../audits/6g4aj7v60syg-2026-08-27-ship-guarded-dependency-mutations-and-graph-queries.md) +found that the guarded substrate was ready but several product contracts remained implicit. The +following clarifications govern the first production dependency commands: + +1. **User references resolve inside the authoritative snapshot.** Dependency planners translate + each task operand to a stable ID from the immutable `TaskGraph`; they never pre-resolve through + the Store and carry a potentially stale choice into the guard. Resolution preserves Taskflow's + ordinary task-reference tiers—exact ID or slug, then unique case-insensitive prefix, then unique + case-insensitive substring—and returns typed missing or ambiguous errors. The matching policy is + shared or parity-tested so guarded and ordinary commands cannot drift. +2. **Legacy migration is an explicit, repository-wide convergence command.** `task depend migrate` + resolves every safe legacy occurrence and inherits the global `--dry-run` and `--json` modes. V1 + has no per-task selector. It rejects an unsafe source before writing and emits a deterministic, + prefix-safe plan. The repository guard serializes the operation, but multiple file replacements + are not one rollback transaction: an attributable sound prefix may remain after failure, and the + same command must resume idempotently from that state. +3. **Public query meanings are narrow and named.** `task blockers` defaults to the actionable + frontier and `--causal` requests the full forensic closure. `task unblocks` reports all transitive + downstream dependents plus their current derived state; it does not claim that satisfying the + source alone makes every result eligible. `task list --unblocked` is the first dispatch-oriented + selector and returns no work with an explicit diagnosis on an unsound relevant graph. There is no + repository-global `task plan` command in this slice; topological waves become public through the + later Thread plan projection. +4. **Receipts distinguish convergence from success.** Edge receipts identify canonical endpoint + IDs and applied versus idempotently skipped operations. Migration receipts expose planned, + applied, skipped, and remaining work. A failure after a durable prefix carries that prefix in + typed human and JSON diagnostics rather than collapsing it into a prose-only error. Diagnostic + query envelopes include graph health and taskflow-owned problems, and all machine results carry + planning-workspace identity where mutation receipts already require it. +5. **Generic editing remains intentionally narrow.** `task edit` continues to reject canonical or + legacy dependency deltas and directs the user to `task depend add/remove`; V1 does not reinterpret + an arbitrary editor-produced graph delta. Query and receipt DTO names remain implementation + details, subject to the reflected schema and human/JSON parity gates, rather than being frozen in + this ADR. + ## Related - Supersedes: [0002-adopt-projects](0002-adopt-projects.md). diff --git a/planning/audits/6g4aj7v60syg-2026-08-27-ship-guarded-dependency-mutations-and-graph-queries.md b/planning/audits/6g4aj7v60syg-2026-08-27-ship-guarded-dependency-mutations-and-graph-queries.md new file mode 100644 index 00000000..5c9c9740 --- /dev/null +++ b/planning/audits/6g4aj7v60syg-2026-08-27-ship-guarded-dependency-mutations-and-graph-queries.md @@ -0,0 +1,235 @@ +--- +schema: 1 +id: 6g4aj7v60syg +bucket: closed +area: ship-guarded-dependency-mutations-and-graph-queries +date: "2026-08-27" +updated_at: "2026-08-27" +--- + +# Audit: Ship Guarded Dependency Mutations and Graph Queries — 2026-08-27 + +Adversarial pre-implementation readiness review of planned task `6g3q4rt7mgjn` (`ship-guarded-dependency-mutations-and-graph-queries`, epic `30-threads-and-task-dependency-graphs`), evaluated against ADR-0006, `docs/ARCHITECTURE.md`, and the merged canonical read (`6g3q4rst78qy`) and portable mutation guard (`6g3q4rt0wzkq`) foundations on `main`. + +**Executive Verdict: Ready with amendments.** The merged foundations on `main` provide a complete and verified substrate: `core.LoadTaskGraph` unifies strict snapshot loading; `store.FS.MutateTaskGraph` provides atomic critical-section isolation and whole-snapshot CAS; `core.ValidateTaskGraphMutationPlan` preserves planner write order while validating all intermediate prefixes and final health; and Tarjan SCC cycle detection correctly attributes multi-cycle feedback topologies. However, before implementation begins on `6g3q4rt7mgjn`, one pre-implementation blocker and two specification gaps must be amended in the task definition: +1. **In-memory reference resolution seam (H1):** `TaskGraph` only looks up tasks by exact 12-character ID. Because planners are strictly forbidden from calling `Store` methods (which fail with `ErrConflict`), the core service cannot resolve user-supplied slugs or prefixes inside the planner callback unless `TaskGraph` provides pure in-memory resolution (`ResolveTask` / `ResolveID`). +2. **Unspecified legacy migration command shape (M1):** The acceptance criteria mandate migrating the 6 live `blocked_by` tasks, but the task specification does not define the CLI command signature (e.g. `tskflwctl task depend migrate [--dry-run]`). +3. **Query Wire DTOs and flag defaults (M2):** DTO schemas for `task blockers` and `task unblocks` and the default projection behavior (action frontier vs causal closure) require explicit definition to avoid wire drift. + +Findings are classified as **[pre-implementation blocker]**, **[amendment required]**, **[follow-up task]**, or **[monitor]**. + +--- + +## Findings + +### High + +#### H1. `TaskGraph` lacks in-memory slug and prefix resolution for mutation planners · **Status:** tracked by 6g3q4rt7mgjn + +**File:** internal/core/dependency_graph.go:729-734, internal/core/store.go:87-91, planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md:24 +**Component:** core / graph snapshot & resolution +**Effort:** S · **Urgency:** acute + +**[pre-implementation blocker]** + +CLI commands like `task depend add --on ` receive human-provided input such as full slugs (`6fjangd7kvh0-alpha`), titles/slugs (`alpha`), short prefixes (`6fjang`), or exact 12-character IDs (`6fjangd7kvh0`). + +Currently, `TaskGraph` only exposes `Task(taskID string) (domain.Task, bool)`, which requires an exact 12-character task ID. It has no in-memory resolver for slugs or prefixes. + +Under the control-inverted mutation guard: +1. Planners run inside `MutateTaskGraph` with `plannerActive` enabled. Calling `s.store.GetTask` or `s.store.ResolveTaskPath` inside the planner is rejected with `ErrConflict` ("graph mutation planner cannot call Store methods"). +2. If the service resolves slugs through the Store *before* calling `MutateTaskGraph`, a TOCTOU race exists where a task renamed, moved, or deleted concurrently results in a stale or invalid mutation. +3. Therefore, all reference resolution must happen *inside* the planner callback against the immutable `*core.TaskGraph` snapshot. + +**Why it matters:** Without an in-memory resolution method on `TaskGraph`, implementers will either hit re-entry deadlocks/conflicts by calling Store methods, or introduce TOCTOU race conditions by pre-resolving outside the lock. + +**Recommendation:** Add a pure in-memory `ResolveTask(ref string) (domain.Task, error)` and `ResolveID(ref string) (string, error)` method to `*core.TaskGraph` that matches exact IDs, exact slugs, and unique unambiguous prefixes against `g.tasks`, returning typed errors (`domain.ErrNotFound`, `domain.ErrAmbiguous`). + +--- + +**Resolution:** Accepted and recorded in ADR-0006 plus task 6g3q4rt7mgjn. The +guarded snapshot gains pure task-reference resolution, refined to preserve the +existing exact ID/slug, case-insensitive prefix, and substring policy through +shared logic or parity tests; the suggested duplicate ResolveTask and ResolveID +surface is not prescribed. + +### Medium + +#### M1. Unspecified CLI subcommand signature and receipt structure for legacy dependency migration · **Status:** tracked by 6g3q4rt7mgjn + +**File:** planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md:25, 36, planning/adrs/0006-adopt-threads-as-task-dags.md:428-432 +**Component:** cli / legacy migration +**Effort:** S · **Urgency:** acute + +**[amendment required]** + +Acceptance criterion 36 requires migrating the six live `blocked_by` fields from resolvable slugs to stable `depends_on` IDs with frontmatter/body preservation. However, neither the task scope nor ADR-0006 defines the exact CLI command path, flags, or output format. + +Without an explicit specification: +1. The implementer must guess whether the command is `tskflwctl task depend migrate`, `tskflwctl task migrate-dependencies`, or an option under `lint --fix`. +2. Diagnostic lint messages (which currently report `"run the guarded migration"`) cannot cite an exact command name. +3. The machine receipt and `--dry-run` behavior for the migration remain underspecified. + +**Recommendation:** Explicitly specify the CLI command signature as `tskflwctl task depend migrate [--dry-run] [--json]` in task `6g3q4rt7mgjn`. Define its receipt to list migrated task IDs, resolved edge additions, cleared legacy fields, and dry-run indicators. + +**Resolution:** Accepted and recorded in ADR-0006 plus task 6g3q4rt7mgjn as +repository-wide task depend migrate. Global dry-run and JSON modes are +inherited, V1 has no per-task selector, and failures report a sound durable +prefix for idempotent retry rather than claiming an all-files transaction. + +#### M2. Query Wire DTOs and default projection flags need explicit specification · **Status:** tracked by 6g3q4rt7mgjn + +**File:** planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md:33-35, 38-40, internal/wire/dto.go +**Component:** wire / cli / query projections +**Effort:** S · **Urgency:** soon + +**[amendment required]** + +The task defines two distinct blocker projections: the action-oriented `BlockingFrontier` (which stops at terminal blockers and unstarted leaves) and the forensic `CausalBlockers` (which traverses the full unsound prerequisite closure). + +However, the task does not define: +1. The CLI flag default for `tskflwctl task blockers ` (whether it defaults to `--frontier` with a `--causal` / `--all` flag, or vice versa). +2. The typed JSON wire structures (`TaskBlockersJSON`, `TaskUnblocksJSON`) in `internal/wire/dto.go`. + +**Recommendation:** +1. Define that `tskflwctl task blockers ` defaults to the action frontier, with a `--causal` (or `--all`) flag to display full transitive causal closure. +2. Specify `TaskBlockersJSON` (with fields `task_id`, `projection`, `blockers: []BlockerJSON`, `health`) and `TaskUnblocksJSON` (with fields `task_id`, `unblocks: []DownstreamTaskJSON`) in `internal/wire/dto.go`. + +--- + +**Resolution:** Accepted in substance and recorded in ADR-0006 plus task +6g3q4rt7mgjn. Blockers defaults to the action frontier with causal closure +selected explicitly; query envelopes require projection, health, structured +problems, and deterministic taskflow-owned data. The audit's tentative Go DTO +names and incomplete field lists are intentionally not frozen. + +### Low + +#### L1. `task list --unblocked` eligibility filter integration over graph snapshots · **Status:** tracked by 6g3q4rt7mgjn + +**File:** planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md:35, 40, planning/adrs/0006-adopt-threads-as-task-dags.md:443 +**Component:** cli / task list / eligibility +**Effort:** S · **Urgency:** eventually + +**[follow-up task / scope clarification]** + +ADR-0006 §8 includes `tskflwctl task list [--unblocked]` as an actionable selector and specifies that it must filter by derived eligibility (`state.Eligible`, requiring `Role == RoleCandidate && Gate == GateClear`) rather than blocker list emptiness. + +While criterion 35 mentions that "frontier/unblocked selectors return no eligible work on an unsound relevant graph", `task list --unblocked` is not explicitly listed in the scope list of task `6g3q4rt7mgjn`. + +**Recommendation:** Clarify in task `6g3q4rt7mgjn` whether `task list --unblocked` is included in this slice or tracked as a fast follow-up alongside eligibility enforcement (`Slice 3`). If included, assert that it filters on `TaskGraph.State(id).Eligible` over a healthy snapshot. + +**Resolution:** Included in the current query slice. task list --unblocked +selects derived Eligible state and fails closed with an explicit graph-health +diagnosis on an unsound relevant graph; lifecycle transition enforcement remains +in its already-sequenced follow-up task. + +#### L2. Mutation receipts should explicitly distinguish modified tasks from idempotent skips · **Status:** tracked by 6g3q4rt7mgjn + +**File:** planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md:31, 49-51, internal/store/graphmutation.go:26 +**Component:** core / cli / receipts +**Effort:** XS · **Urgency:** eventually + +**[monitor during implementation]** + +When `task depend add A --on B` is executed where `B` is already a prerequisite of `A`, the mutation is an idempotent skip: no file is written and `updated_at` is not bumped. When `task depend add A --on B --on C` is executed where `B` is new and `C` is already present, `A` is modified, but only `B` represents an added edge. + +Machine and human receipts should clearly separate applied changes from skipped/no-op targets so downstream automation and agents do not mistake a no-op for a state change. + +**Recommendation:** Structure `DependencyMutationReceipt` with `TaskID`, `AddedDependencies: []string`, `RemovedDependencies: []string`, `SkippedDependencies: []string`, `Modified: bool`, and `DryRun: bool`. + +--- + +**Resolution:** Accepted and strengthened in task 6g3q4rt7mgjn. Edge receipts +distinguish applied and idempotently skipped outcomes with canonical endpoints, +changed, dry-run, and workspace identity; migration and error output must also +preserve the durable applied prefix and remaining resumable work. + +## Decisions Requiring Owner Input + +1. **CLI Command Name for Legacy Migration:** + - *Option A (Recommended):* `tskflwctl task depend migrate [--dry-run] [--json]` — groups the migration under the `task depend` namespace. + - *Option B:* `tskflwctl task migrate-dependencies [--dry-run] [--json]` — top-level verb under `task`. +2. **Default Projection for `task blockers`:** + - *Option A (Recommended):* Default to action frontier (`BlockingFrontier`), with `--causal` flag for full closure. This gives users immediate actionable items first. + - *Option B:* Default to full causal closure (`CausalBlockers`), with `--frontier` flag for action frontier. +3. **Handling of `task edit` Dependency Deltas:** + - *Option A (Recommended):* Strict rejection (currently implemented on `main`). If a user modifies `depends_on` or legacy fields in `task edit`, reject with: `"task edit cannot modify depends_on or legacy dependency fields; use task depend add/remove"`. This keeps the interactive editor session 100% outside the mutation guard. + - *Option B:* Automatically pipe the parsed delta from `task edit` into `Service.AddDependency/RemoveDependency` after the editor exits. (Adds complexity and potential re-entry edge cases). + +--- + +## Proposed Amendments to Task `6g3q4rt7mgjn` + +1. **Update Scope Section:** + - Add `task depend migrate [--dry-run]` to the explicit scope list. + - Specify `task blockers [--causal]` and `task unblocks` command signatures. + - Add pure in-memory slug/prefix resolution (`TaskGraph.ResolveTask`) to the core graph scope. +2. **Update Acceptance Criteria:** + - Add: `TaskGraph exposes pure in-memory slug and prefix resolution so planners resolve user inputs without Store re-entry.` + - Add: `task depend migrate converts all resolvable legacy blocked_by/dependencies/blocks references to depends_on, cleans legacy frontmatter, and is idempotent on clean repositories.` + - Clarify: `task edit strictly rejects dependency field modifications and directs users to task depend add/remove.` +3. **Add Wire DTO Definitions:** + - Specify `TaskBlockersJSON`, `TaskUnblocksJSON`, `DependencyMutationReceiptJSON`, and `DependencyMigrationReceiptJSON` in `internal/wire/dto.go`. + +--- + +## Prioritized Test Matrix + +| Priority | Test Category | Scenario & Invariants | +|---|---|---| +| **P0** | Mutation & Cycle Prevention | Add edge `A -> B -> A` fails with `ErrValidation` naming the cycle path; multi-node cycle `A -> B -> C -> A` fails closed; concurrent opposite edge additions across goroutines allow exactly one success and one cycle failure. | +| **P0** | Legacy Migration | Migrate the 6 live repository `blocked_by` tasks in a temporary fixture; verify all frontmatter comments, custom fields, and bodies are preserved; verify `blocked_by` is removed and `depends_on` contains resolved stable IDs; verify repository health transitions from `degraded` to `healthy`; verify subsequent run is a no-op. | +| **P0** | In-Memory Slug Resolution | Verify `TaskGraph.ResolveTask` resolves exact 12-char IDs, full slugs, unique slug prefixes, and returns `ErrNotFound` or `ErrAmbiguous` without invoking `Store` methods. | +| **P1** | Idempotency & No-Op | Adding already-present dependency produces 0 writes, does not bump `updated_at`, exits 0, and returns `skipped` receipt; removing absent dependency produces 0 writes, exits 0, and returns `skipped` receipt. | +| **P1** | Blocker Queries | Query `task blockers` on clear, blocked, unsound-completed, parked, withdrawn, and cyclic tasks; verify deterministic shortest paths, direct/transitive flags, and reason tokens; verify `--causal` vs `--frontier` outputs. | +| **P1** | Downstream Queries | Query `task unblocks` on root and intermediate tasks; verify transitive downstream dependent list and gate states. | +| **P2** | Dry-Run Fidelity | `--dry-run` on `add`, `remove`, and `migrate` runs identical snapshot loading, in-memory resolution, cycle checking, prefix validation, and receipt generation with 0 disk writes. | +| **P2** | `task edit` Guard | Verify `task edit` rejecting dependency field modifications with clear guidance to `task depend add/remove`. | +| **P3** | Wire DTO & JSON Parity | Verify JSON schema validation and stdout formatting across all mutation receipts and query DTOs. | + +--- + +## Traceability Table + +| Finding | Severity | Classification | Target Action | +|---|---|---|---| +| **H1** (In-memory slug resolution in `TaskGraph`) | High | Pre-implementation blocker | Amend `6g3q4rt7mgjn` to implement `TaskGraph.ResolveTask` before mutation service | +| **M1** (Unspecified legacy migration command shape) | Medium | Amendment required | Amend `6g3q4rt7mgjn` with `task depend migrate` command spec | +| **M2** (Query Wire DTOs and default projection flags) | Medium | Amendment required | Amend `6g3q4rt7mgjn` with DTO schemas and `--frontier` default | +| **L1** (`task list --unblocked` filter) | Low | Scope clarification / Follow-up | Clarify scope in `6g3q4rt7mgjn` or track in Slice 3 | +| **L2** (Receipt skip vs modified distinction) | Low | Monitor during implementation | Implement structured receipt in `Service` | + +--- + +## Validation Commands and Results + +All checks executed on `main` (commit `90bfabe`): + +1. **Full Test Suite:** + ```bash + go test ./... + ``` + *Result:* Passed (all 25 packages ok). + +2. **Race Detector:** + ```bash + go test -race ./... + ``` + *Result:* Passed (0 data races). + +3. **Audit Lint Validation:** + ```bash + tskflwctl audit lint 2026-08-27-ship-guarded-dependency-mutations-and-graph-queries + ``` + *Result:* Passed. + +## Candidate tasks + +- ⏳ `tskflwctl task new "Add pure in-memory reference resolution to TaskGraph" --epic 30-threads-and-task-dependency-graphs --tags core,graph` — Implement ResolveTask and ResolveID on TaskGraph for mutation planners (H1) +- ⏳ `tskflwctl task new "Add task depend migrate CLI command and DTOs" --epic 30-threads-and-task-dependency-graphs --tags cli,wire` — Define task depend migrate command and receipt DTOs (M1) +- ⏳ `tskflwctl task new "Define blocker and unblocks wire DTOs and CLI flags" --epic 30-threads-and-task-dependency-graphs --tags wire,cli` — Define TaskBlockersJSON and TaskUnblocksJSON schemas and CLI flag defaults (M2) + +## Closeout disposition + +All five findings are tracked by task 6g3q4rt7mgjn and the durable product contracts are recorded in ADR-0006. The three candidate tasks above were intentionally not created: resolver, migration, query, and receipt work are inseparable acceptance scope for this production slice. The illustrative DTO names remain non-binding. Closeout also adds the audit gaps around downstream-query semantics, the absence of a repository-global task plan command, and structured recovery data after a durable multi-file prefix. diff --git a/planning/audits/6g4g3wyrr4ns-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-antigravity.md b/planning/audits/6g4g3wyrr4ns-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-antigravity.md new file mode 100644 index 00000000..fc8551f2 --- /dev/null +++ b/planning/audits/6g4g3wyrr4ns-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-antigravity.md @@ -0,0 +1,203 @@ +--- +schema: 1 +id: 6g4g3wyrr4ns +bucket: closed +area: guarded-dependency-mutations-and-graph-queries-implementation-antigravity +date: "2026-08-28" +updated_at: "2026-08-28" +--- + +# Audit: Guarded Dependency Mutations and Graph Queries Implementation Review — 2026-08-28 + +> Edit findings in place and flip each `**Status:**` as you work it. + +Adversarial implementation review of the guarded task-dependency operations slice (task `6g3q4rt7mgjn`, epic `30-threads-and-task-dependency-graphs`, branch `feat/guarded-dependency-operations`, uncommitted tree over merge-base `90bfabe`), evaluated against ADR-0006 (including the 2026-08-27 amendment), the readiness audit `6g4aj7v60syg`, and `docs/ARCHITECTURE.md`. + +**Executive Verdict: Ready with required fixes.** The foundational architecture is sound: reference resolution operates entirely in-memory within the authoritative snapshot; cross-process mutation exclusion and multi-level CAS protect against cooperating and non-cooperating writers; Tarjan SCC cycle detection correctly attributes multi-cycle feedback topologies; and legacy migrations successfully converged the six live planning occurrences to eight canonical `depends_on` edges. However, adversarial stress testing across query projections, migration failure modes, and user-facing diagnostics reveals four acute/moderate issues that should be addressed before task `6g3q4rt7mgjn` is closed: + +1. **False affirmative in `task blockers` and `task unblocks` on degraded graphs (H1):** `g.dependencies` and `g.outgoing` are populated only from canonical `depends_on`. On a degraded graph with unmigrated legacy fields, `task blockers` reports `✔ no blockers` and `"blockers":[]` for a candidate task whose legacy prerequisite is unstarted, and `task unblocks` reports `• no downstream tasks`. +2. **Missing regression test for prefix-safe migration write order (H2):** The reverse topological wave write ordering (`leftWave > rightWave`) in `planLegacyDependencyMigration` is correct, but reversing the comparison leaves all committed tests passing while silently destroying edges on an interrupted `blocks`-only migration. +3. **Queried task derived state omitted from blocker/unblock envelopes (M1):** `TaskBlockersEnvelope` and `TaskUnblocksEnvelope` omit the queried task's own `TaskGraphStateJSON` (role, gate, eligible), forcing consumers to infer authorization from an empty blocker list. +4. **Stale diagnostic guidance citing unavailable commands (M2):** `task set` rejects dependency changes citing "`task depend add/remove` once available" and `lint` cites "canonical migration is intentionally deferred to guarded dependency operations", despite both shipping in this branch. + +--- + +## Findings + +#### H1. Degraded graphs make `task blockers` and `task unblocks` emit false affirmative all-clear responses · **Status:** fixed + +**File:** `internal/core/dependency_graph.go:369,389-390,404-405,414-416`, `internal/cli/render/dependency.go:61-63,89-91` | **Component:** core/query projections, cli/render +**Effort:** M · **Urgency:** acute + +`g.dependencies` and `g.outgoing` are populated exclusively from canonical `Task.DependsOn` fields. Resolved legacy edges from `resolveLegacyDiagnostics` are appended to `projectedEdges` solely for cycle and topological wave analysis in `analyzeDAG`. They are not merged into `g.dependencies` or `g.outgoing`. + +As a consequence, when a repository is in the `degraded` state (e.g. before `task depend migrate` has been run): +- `task blockers ` walks `g.unsoundPrerequisites` and finds zero canonical dependencies, printing `✔ no blockers` and emitting `"blockers": []`. +- `task unblocks ` walks `g.outgoing` and finds zero canonical dependents, printing `• no downstream tasks` and emitting `"unblocks": []`. + +**Reproducible Example:** +Given task `alpha` (`ready-to-start`) and task `beta` (`ready-to-start`, `blocked_by: [alpha]`): +```text +$ tskflwctl task blockers beta +beta (6g0000000b02) +graph: degraded +view: frontier +✔ no blockers +⚠ legacy blocked_by on 6g0000000b02; run task depend migrate +``` + +Although `graph: degraded` is displayed, the human green checkmark `✔ no blockers` and machine `"blockers": []` invite the inference that `beta` has no blockers. + +**Recommendation:** In `render.TaskBlockersHuman` and `render.TaskUnblocksHuman`, replace `✔ no blockers` / `• no downstream tasks` with an explicit notice (e.g. `• no canonical blockers; N legacy field(s) unprojected — run task depend migrate`) when `result.Health != GraphHealthy`. Additionally, in wire envelopes, include `legacy_dependencies_unprojected: true` or project resolved legacy edges directly into diagnostic graph traversals. + +**Resolution:** Projected exactly resolved legacy edges into diagnostic +traversals and derived gates; degraded blocker and downstream regression cases +now return the constraint. + +#### H2. Prefix-safe migration write order lacks test coverage; inverted order silently drops edges on failure · **Status:** fixed + +**File:** `internal/core/dependency_operations.go:320-326`, `internal/store/dependency_operations_test.go:102-144` | **Component:** core/migration planner, store regression tests +**Effort:** S · **Urgency:** acute + +`planLegacyDependencyMigration` orders multi-file writes in reverse topological wave order (`leftWave > rightWave`), writing dependents before prerequisites. This ensures that when a prerequisite clears its legacy `blocks` declaration, the dependent has already durably written the canonical `depends_on` edge. + +However, existing tests in `dependency_operations_test.go` only test fixtures where dependencies are declared symmetrically (`blocks` on owner AND `blocked_by` on dependent) or via `blocked_by` alone. If the sort comparison in `dependency_operations.go:323` is inverted to `leftWave < rightWave`, all 25 test packages still pass 100%. + +If an inverted migration is interrupted after the first write on a `blocks`-only fixture: +1. The prerequisite file is written first, clearing `blocks: [dependent]`. +2. The write fails before updating the dependent. +3. On retry, the prerequisite no longer has `blocks`, and the dependent never had `blocked_by`. The edge vanishes completely and the graph is falsely declared `healthy`. + +**Recommendation:** Add a test in `internal/store/dependency_operations_test.go` with a `blocks`-only fixture (owner has `blocks: [dependent]`, dependent has no legacy fields), inject a failure after the first write via `testHookAfterGraphWrite`, and verify that the first write modified the dependent (adding `depends_on`), leaving the owner's `blocks` intact until the second write. + +**Resolution:** Added a blocks-only interrupted migration test that pins +dependent-first write order and convergent retry. + +#### M1. Query wire envelopes omit derived state for the queried task · **Status:** fixed + +**File:** `internal/wire/dependency.go:107-115, 143-150`, `internal/cli/render/dependency.go:108-116` | **Component:** wire/query DTOs, cli/render +**Effort:** S · **Urgency:** soon + +`TaskBlockersEnvelope` and `TaskUnblocksEnvelope` include `TaskGraphStateJSON` for every blocker and downstream task, but only provide `GraphTaskJSON` (id, slug, status, epic) for the *queried* task itself. The queried task's own `Role`, `Gate`, and `Eligible` fields are omitted. + +Per `docs/ARCHITECTURE.md`, "Eligibility is read from derived state, never inferred from an empty blocker list". Because `TaskBlockersEnvelope` does not return the queried task's derived state, machine consumers querying `task blockers ` have no choice but to infer eligibility from an empty blocker list. + +**Recommendation:** Add `State TaskGraphStateJSON json:"state"` for the root task in `TaskBlockersEnvelope` and `TaskUnblocksEnvelope`, and render `role/gate` in `graphQueryHeader`. + +**Resolution:** Queried-task state is present in both wire envelopes and human +headers; schema version is 1.51. + +#### M2. Diagnostic and rejection messages contain stale guidance citing unavailable commands · **Status:** fixed + +**File:** `internal/core/service_task.go:332-337`, `internal/core/service.go:401`, `internal/core/dependency_graph_mutation.go:116`, `internal/core/dependency_operations.go:446` | **Component:** core/diagnostic messages +**Effort:** XS · **Urgency:** soon + +Several error and lint messages across the codebase still state that guarded commands are unavailable: +- `service_task.go:334`: `task set` rejection says `(`task depend add/remove` once available)`. +- `service.go:401`: `lint` diagnostic says `canonical migration is intentionally deferred to guarded dependency operations`. +- `dependency_graph_mutation.go:116` and `dependency_operations.go:446`: duplicate helper functions cite `run the guarded migration` vs `run task depend migrate`. + +**Recommendation:** Update `service_task.go` to state `use guarded dependency operations (task depend add/remove)` and unify `graphMutationHealthDetail` and `taskGraphHealthDetail` into a single shared helper. + +**Resolution:** Guidance now names exact shipping commands and one shared +health-detail helper owns the wording. + +#### L1. `TaskGraph.ResolveTaskID` omits discarded duplicate-ID sibling slugs from candidate resolution on broken graphs · **Status:** fixed + +**File:** `internal/core/dependency_graph.go:346-349, 762-765` | **Component:** core/graph resolution, diagnostic addressability +**Effort:** XS · **Urgency:** eventually + +When multiple files declare the exact same task ID, `NewTaskGraph` retains only the first task in `g.tasks` and `g.ids`. `ResolveTaskID` builds candidates from `g.ids`, so exact ID lookup returns the first task ID rather than `domain.ErrAmbiguous`, and querying the second task's slug returns `domain.ErrNotFound`. Store resolution (`store.ResolveTaskPath`) preserves all filesystem candidates and returns `domain.ErrAmbiguous`. + +**Impact:** Contained to broken repositories where duplicate task IDs exist. All mutations already fail closed via `ValidateTaskGraphMutationSource`. + +**Resolution:** The snapshot retains one resolution candidate per file, +preserving duplicate-ID ambiguity and sibling slug diagnostics. + +#### L2. Downstream and causal query output scales quadratically with graph depth · **Status:** deferred + +**File:** `internal/core/dependency_graph.go:1039-1076` | **Component:** core/query performance, envelope size +**Effort:** S · **Urgency:** eventually + +`DownstreamImpact` and `CausalBlockers` materialize full path arrays for each reachable node. For a linear chain of depth $N$, the serialized JSON envelope produces $\Theta(N^2)$ path elements. On a 1,500-task chain, this generates ~17MB of JSON. + +**Impact:** Negligible on realistic human planning repositories (typical depth $\le 10$). Stress tests should document this envelope bound. + +**Resolution:** A bounded 512-edge test records quadratic full-path +amplification. Capping remains deferred until dogfood exceeds the current live +depth of six. + +#### L3. Present-but-empty legacy fields are ignored by migration · **Status:** fixed + +**File:** `internal/core/dependency_graph.go:551-553` | **Component:** core/legacy migration +**Effort:** XS · **Urgency:** eventually + +`resolveLegacyDiagnostics` skips fields where `len(values) == 0`. A task with `blocked_by: []` or `blocks: []` is not diagnosed as degraded and is not cleared by `task depend migrate`. + +**Impact:** Zero semantic impact, as empty legacy arrays declare no dependency constraints. + +--- + +**Resolution:** Store parsing now preserves legacy key presence, so empty keys +remain degraded, migratable, and receipt-bearing. + +## Traceability Table + +| Acceptance Criterion | Status | Implementation Seam | Test Coverage | +| :--- | :---: | :--- | :--- | +| **1. Snapshot reference resolution**
Matches ordinary resolution for exact IDs/slugs, case-insensitive prefixes, substrings, missing, ambiguity without calling Store. | **Fulfilled** (with L1 caveat) | `internal/core/dependency_graph.go:752-809`
[`*TaskGraph.ResolveTaskID`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_graph.go#L752-L809) | `internal/core/dependency_graph_test.go:225-266`
`TestTaskGraphResolveTaskIDMatchesRepositoryReferenceTiers` | +| **2. Add/Remove mutation validation**
Validates exact canonical endpoints, duplicate/self/missing edges, proposed union, cycles, every durable prefix, and final health. | **Fulfilled** | `internal/core/dependency_operations.go:176-230`
[`planDependencyEdges`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L176-L230), `internal/core/dependency_graph_mutation.go:29-97`
[`ValidateTaskGraphMutationPlan`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_graph_mutation.go#L29-L97) | `internal/core/dependency_operations_test.go:121-143`
`TestServiceDependencyMutationRejectsAmbiguousDuplicateSelfAndCycle` | +| **3. Idempotent no-op preservation**
Already-present adds and absent removals succeed as receipt-bearing no-ops with zero byte or timestamp change. | **Fulfilled** | `internal/core/dependency_operations.go:208-228`
[`planDependencyEdges`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L208-L228), `internal/store/graphmutation.go:155-161`
[`materializeTaskGraphPlan`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/store/graphmutation.go#L155-L161) | `internal/cli/task_dependency_test.go:45-129`
`TestTaskDependAddRemoveJSONDryRunAndNoop` | +| **4. Authoritative dry-run execution**
`--dry-run` executes authoritative resolution, planning, and validation with zero replacements. | **Fulfilled** | `internal/store/graphmutation.go:73-75`
[`MutateTaskGraph`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/store/graphmutation.go#L73-L75), `internal/core/dependency_operations.go:124-150`
[`runDependencyMutation`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L124-L150) | `internal/core/dependency_operations_test.go:70-119`
`TestServiceDependencyAddRemoveDryRunAndIdempotence` | +| **5. Legacy dependency migration**
`task depend migrate` converts the six live legacy occurrences with frontmatter/body preservation; unsafe state writes nothing. | **Fulfilled** | `internal/core/dependency_operations.go:232-340`
[`planLegacyDependencyMigration`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L232-L340), `internal/store/dependency_operations_test.go:17-67` | `internal/store/dependency_operations_test.go:17-67`
`TestDependencyMigrationPreservesBodyCommentsAndConverges` | +| **6. Migration prefix-safety and retry convergence**
Migration remains sound after every injected write failure; retry converges from every durable prefix. | **Requires Amendment** (H2) | `internal/core/dependency_operations.go:306-327`
Reverse topological wave write ordering | `internal/store/dependency_operations_test.go:102-144`
`TestDependencyMigrationEveryDurablePrefixStaysSoundAndResumes` | +| **7. Mutation receipts & recovery data**
Receipts distinguish applied and skipped edges; partial-failure diagnostics preserve applied and remaining tasks. | **Fulfilled** | `internal/core/dependency_operations.go:151-174`
[`dependencyReceipt`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L151-L174), `internal/cli/task_dependency.go:25-37`
[`dependencyFailure`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/cli/task_dependency.go#L25-L37), `internal/cli/exit.go:80-84` | `internal/cli/task_dependency_test.go:303-328`
`TestWriteErrorCarriesStructuredDependencyMutationRecovery` | +| **8. Explanatory blocker query**
`task blockers` defaults to action frontier, `--causal` returns full closure, exposing deterministic reasons and shortest paths. | **Requires Amendment** (H1, M1) | `internal/core/dependency_graph.go:866-933`
[`CausalBlockers`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_graph.go#L866-L875), [`BlockingFrontier`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_graph.go#L879-L888) | `internal/cli/task_dependency_test.go:232-291`
`TestTaskGraphQueryCommandsAndUnblockedSelector` | +| **9. Explanatory downstream query**
`task unblocks` reports transitive downstream dependents and current state without claiming counterfactual eligibility. | **Requires Amendment** (H1, M1) | `internal/core/dependency_graph.go:1036-1075`
[`DownstreamImpact`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_graph.go#L1036-L1075), `internal/cli/render/dependency.go:87-106` | `internal/core/dependency_graph_test.go:268-299`
`TestTaskGraphDownstreamImpactUsesDeterministicShortestPaths` | +| **10. Fail-closed unblocked selector**
`task list --unblocked` filters on derived eligibility and returns no dispatchable work on an unsound relevant graph. | **Fulfilled** | `internal/core/service_task.go:47-53`
[`Service.ListTasks`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/service_task.go#L47-L53), `internal/core/dependency_graph.go:707` | `internal/cli/task_dependency_test.go:293-301`
`TestTaskListUnblockedFailsClosedOnBrokenGraph` | +| **11. Graph ownership enforcement**
`task set --force` and `task edit` cannot bypass graph ownership or guarded dependency validation. | **Fulfilled** (with M2 message fix) | `internal/core/service_task.go:273-277`
[`Service.SetFields`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/service_task.go#L273-L277), `internal/store/edit.go:169-176`
[`FS.EditTask`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/store/edit.go#L169-L176) | `internal/store/dependency_persistence_test.go:62-135`
`TestEditTaskRejectsDependencyDeltaButAllowsReordering` | +| **12. Reflected schema & wire contract**
Query and mutation JSON envelopes enter reflected schema and preserve human/machine semantic parity. | **Fulfilled** (with M1 schema update) | `internal/wire/dependency.go:1-229`, `internal/wire/envelopes.go:1054-1056`, `internal/wire/wire.go:173-177` | `internal/wire/envelopes_test.go:70-102`
JSON Schema compilation and validation test suite | +| **13. Service clock and timestamp hygiene**
Semantic changes use the Service clock exactly once; no-ops do not advance `updated_at`. | **Fulfilled** | `internal/core/dependency_operations.go:128`
[`Service.runDependencyMutation`](file:///Users/andyeschbacher/git/andy-esch/taskflow/internal/core/dependency_operations.go#L128), `internal/store/graphmutation.go:162-163` | `internal/store/dependency_operations_test.go:17-67`
`TestDependencyMigrationPreservesBodyCommentsAndConverges` | +| **14. Deep-chain stress envelope**
Deep-chain stress establishes supported depth envelope without unsafe stack or latency bounds. | **Fulfilled** (with L2 monitor) | `internal/core/dependency_graph.go:622-655`
Memoized sound derivation, iterative BFS | `internal/core/dependency_graph_test.go:327-361`
`TestTaskGraphSupportedDeepChainEnvelope` (4,096 edges) | +| **15. Dogfood bootstrap persisted**
Production commands persist Threads bootstrap dependency edges and exercise queries against the real graph. | **Fulfilled** | `planning/tasks/6g3q4rt7mgjn...`
`planning/tasks/6g3q4rte8kc1...`
`planning/tasks/6g3q4rtmv4ak...` | `tskflwctl lint`
`tskflwctl task blockers 6g3q4rte8kc1`
`tskflwctl task unblocks 6g3q4rt7mgjn` | + +--- + +## Explicit Rejected Concerns + +1. **Planner Store Re-entry & TOCTOU Resolution:** + - *Investigation:* Analyzed whether human-provided references could trigger store deadlocks, conflict errors, or TOCTOU races if resolved outside or inside the lock. + - *Finding:* `TaskGraph.ResolveTaskID` executes entirely in-memory over the immutable snapshot loaded under the exclusive write lock. Store re-entry is blocked by `FS.rejectGraphPlannerCall()`, and parity tests verify exact matching behavior against Store candidate resolution. +2. **Multi-Cycle SCC Exponential Enumeration:** + - *Concern:* Cyclic components with multiple overlapping cycles could trigger exponential path enumeration. + - *Finding:* Tarjan's SCC algorithm runs in linear $O(V+E)$ time, marks all cyclic SCC members exactly, and extracts a single edge-following representative cycle path without combinatorial explosion. +3. **Partial Mutation Recovery Misrepresenting Applied Edges:** + - *Concern:* Pre-write failures might emit `dependency_mutation` envelopes that imply edges were applied. + - *Finding:* `cli.dependencyFailure` attaches `dependency_mutation` only when `len(receipt.AppliedTaskIDs) > 0`. Pre-write failures emit standard `ErrorEnvelope` structures without misleading edge states. +4. **Cyclic Self-Reference in Downstream Impact:** + - *Concern:* `task unblocks` on a cyclic task might list the task as downstream of itself. + - *Finding:* `DownstreamImpact` marks the origin as visited and explicitly skips `dependent == taskID`, preventing self-referential impact output. +5. **No-op Idempotence and Timestamp Preservation:** + - *Concern:* Re-running `task depend add` on an existing edge might advance `updated_at`. + - *Finding:* `FS.materializeTaskGraphPlan` detects `bytes.Equal(content, newContent)` and skips writing without stamping `updated_at`. + +--- + +## Validation Commands and Results + +```bash +GOCACHE=/tmp/taskflow-review-go-cache go test ./... +# Result: ok across all 25 packages (0 failures) + +GOCACHE=/tmp/taskflow-review-go-cache go test -race ./... +# Result: ok across all packages (0 data races) + +GOLANGCI_LINT_CACHE=/tmp/taskflow-review-lint-cache golangci-lint run ./... +# Result: 0 issues + +git diff --check +# Result: clean diff hygiene + +go run ./cmd/tskflwctl lint +# Result: ✔ all active tasks and epics pass lint +``` diff --git a/planning/audits/6g4g61bsbzq3-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-claude.md b/planning/audits/6g4g61bsbzq3-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-claude.md new file mode 100644 index 00000000..2006cb08 --- /dev/null +++ b/planning/audits/6g4g61bsbzq3-2026-08-28-guarded-dependency-mutations-and-graph-queries-implementation-claude.md @@ -0,0 +1,658 @@ +--- +schema: 1 +id: 6g4g61bsbzq3 +bucket: closed +area: guarded-dependency-mutations-and-graph-queries-implementation-claude +date: "2026-08-28" +updated_at: "2026-08-28" +--- + +# Audit: guarded-dependency-mutations-and-graph-queries-implementation-claude — 2026-08-28 + +> Edit findings in place and flip each `**Status:**` as you work it. + +Adversarial implementation review of the guarded task-dependency operations slice +(task `6g3q4rt7mgjn`, epic `30-threads-and-task-dependency-graphs`, branch +`feat/guarded-dependency-operations`, uncommitted tree over merge-base `90bfabe`), +evaluated against ADR-0006 (including the new 2026-08-27 amendment), the readiness +audit `6g4aj7v60syg`, and `docs/ARCHITECTURE.md`. + +Method: every claim below was executed against a built binary and throwaway +repositories in a scratch directory, never against the real planning tree. Two +invariants were additionally checked by **mutation testing** — deliberately breaking +the implementation in a copy of the tree outside the repository and re-running the +committed suite to see whether anything fails. `git status --short` reports the same +69 entries before and after this review; this audit file is the only repository write. + +## Executive verdict + +**Ready with required fixes.** The hard parts are right and survived attack. Reference +resolution genuinely happens inside the authoritative snapshot and is byte-identical to +`store.resolveID` across exact/case/prefix/substring/ambiguity/unsafe-name/duplicate-slug +and unreadable-file inputs. Two real OS processes racing opposite edges produced exactly +one commit and one cycle refusal with an acyclic final graph. An interrupted multi-file +migration (forced with a real `EPERM` on the second rename, not a test hook) left a +degraded-but-sound durable prefix, emitted structured `applied`/`remaining` recovery data +on the error envelope, printed human retry guidance, left no temp files, and converged on +rerun. No-op adds and removes are byte-identical with no `updated_at` advance. The +migration write order really is prefix-safe, and the real planning tree's six legacy +occurrences converted to eight canonical edges with correct direction. + +Four things should be fixed before this task closes: + +1. **The two new explanatory commands report an affirmative all-clear on a `degraded` + graph** (H1). `task blockers` prints a green `✔ no blockers` and returns + `"blockers":[]` for a task whose resolvable legacy prerequisite is not started; + `task unblocks` returns `"unblocks":[]` for that prerequisite. After `task depend + migrate` the same repository reports the constraint correctly. Degraded is exactly + the state this slice exists to leave, and `task start` does not yet enforce + eligibility, so a false all-clear is actionable today. +2. **The migration's prefix-safe write order — the one invariant standing between an + interrupted `blocks` migration and permanent, silent edge loss — has no test** (H2). + Reversing the wave comparison leaves all 23 packages green, and the resulting binary + destroys the edge irrecoverably under the same injected failure the correct binary + survives. +3. **`task blockers` carries no derived state for the queried task** (M1), so the only + thing an agent can read from a clean result is the empty blocker list — precisely the + inference `docs/ARCHITECTURE.md` says must never be made. +4. **User-facing guidance still says the guarded commands are unavailable** (M3): + `task set` answers "`task depend add/remove` once available" and `lint` answers + "canonical migration is intentionally deferred to guarded dependency operations", + both of which are now false. + +The remaining findings are genuine but lower-stakes, and two of them are better as +separate follow-ups. + +## Findings + +#### H1. Degraded snapshots make `task blockers` and `task unblocks` report a false all-clear · **Status:** fixed + +**File:** internal/core/dependency_graph.go:369,389-390,414-416 | **Component:** core/graph, cli/render +**Effort:** M · **Urgency:** acute + +`g.dependencies` and `g.outgoing` are populated only from canonical `depends_on` +(dependency_graph.go:369, 389-390, 404-405). Resolved legacy edges are appended to +`projectedEdges` for `analyzeDAG` alone (dependency_graph.go:414-416), so they influence +cycle attribution but never enter the traversals that `projectBlockers` +(dependency_graph.go:890) and `DownstreamImpact` (dependency_graph.go:1039) walk. Both new +commands therefore answer as if the legacy constraints did not exist, while reporting +`health: degraded` and a separate `legacy_dependencies` array. + +Reproduced end to end. Two tasks, `alpha` (`ready-to-start`) and `beta` +(`ready-to-start`, `blocked_by: [alpha]`): + +``` +$ tskflwctl task blockers beta +beta (6g0000000b02) +graph: degraded +view: frontier +✔ no blockers +⚠ legacy blocked_by on 6g0000000b02; run task depend migrate + +$ tskflwctl task blockers beta --causal --json +… "health":"degraded","blockers":[], … + +$ tskflwctl task unblocks alpha +alpha (6g0000000a01) +graph: degraded +view: downstream impact +• no downstream tasks + +$ tskflwctl task depend migrate >/dev/null +$ tskflwctl task blockers beta +• alpha not-started direct +$ tskflwctl task unblocks alpha +• beta candidate/blocked direct +``` + +The same repository, same constraint, opposite answers on either side of a migration +that ADR-0006 explicitly says preserves semantics. `--causal` — the projection sold as +"the full forensic closure" — is equally empty. + +**Why it matters.** Degraded is not an exotic state: it is the state of any repository +that has not yet run `migrate`, that a colleague's older binary or a hand edit +reintroduces, or that a partially-applied migration leaves behind (H2's recovery path +lands there by design). The failure mode is a green checkmark and an empty machine array +in answer to "is anything blocking this?", which is the one question where a false +negative causes work to start on an unsatisfied prerequisite. `task list --unblocked` +does fail closed on degraded, and the eligibility guard for `task start` is the *next* +slice (`6g3q4rte8kc1`), so nothing else catches this today. The `legacy_dependencies` +array is a real mitigation only for a consumer who already knows to distrust `blockers`. + +This is arguably inherited projection behavior, but this slice is what turns it into a +public product answer, so it belongs here. + +**Recommendation:** Smallest correction: stop emitting an affirmative empty result when +the snapshot is not healthy. In `render.TaskBlockersHuman`/`TaskUnblocksHuman` +(cli/render/dependency.go:61-63, 89-91) replace `✔ no blockers` / `• no downstream +tasks` with an explicit "no canonical constraints; N legacy occurrence(s) are not +projected into this view — run `task depend migrate`" whenever `Health != healthy`, and +add a boolean such as `legacy_constraints_unprojected` to both envelopes so a machine +consumer sees the same caveat. The larger correction — projecting resolved legacy edges +into the diagnostic blocker/downstream traversals so degraded reads are simply correct — +is a defensible follow-up, but the misleading affirmative must not ship. + +**Resolution:** Projected every exactly resolved legacy edge into blocker, +downstream, gate, and sound-completion reads; focused core and CLI tests cover +degraded snapshots. + +#### H2. The prefix-safe migration write order has no test; reversing it silently destroys edges · **Status:** fixed + +**File:** internal/core/dependency_operations.go:302-326 | **Component:** core/migration planner, store tests +**Effort:** S · **Urgency:** acute + +`planLegacyDependencyMigration` orders writes by descending topological wave +(dependency_operations.go:320-326) so a dependent gains its canonical edge before the +prerequisite that declared it via legacy `blocks` clears that declaration. The comment at +302-305 states exactly why. The implementation is correct — I confirmed the plan order +on a `blocks`-only fixture: + +``` +$ tskflwctl task depend migrate --dry-run --json | jq .planned_task_ids +["6g0000000b02", "6g0000000a01"] # dependent first, clear-owner second +``` + +**No test defends it.** I copied the tree outside the repository, changed line 323 from +`return leftWave > rightWave` to `return leftWave < rightWave`, and ran the committed +suite: 23 packages ok, zero failures. The three migration tests in +`internal/store/dependency_operations_test.go` cannot detect the reversal, because in +every fixture the edge is declared from *both* directions (`blocks` on one task and +`blocked_by` on the other) or from `blocked_by` alone — so a wrong order still converges +on retry. + +The reversal is not benign. Building the mutant binary and forcing the second rename to +fail (`chflags uchg` on the second target, an ordinary `EPERM`, no test hook) on a +`blocks`-only fixture: + +``` +mutant plan: ["6g0000000a01", "6g0000000b02"] # clear-owner first +after failure: (no dependency fields anywhere) +retry: changed=False edges=[] +final: health=healthy, blockers=[] +``` + +The legacy declaration is cleared, the canonical edge was never written, the retry +reports nothing to do, and the graph is reported **healthy**. That is silent, +unrecoverable loss of a planning constraint. The correct binary under the identical +failure leaves `blocks: [beta]` on the owner plus `depends_on: [alpha]` on the +dependent — degraded, diagnosable, and convergent on rerun. + +**Why it matters.** Acceptance criterion "Migration remains graph-sound after every +injected write failure and retry converges from every durable prefix" is ticked, but the +single ordering case where soundness is load-bearing is untested. Any future refactor of +the ordering block — including a plausible "simplify to plain ID order" cleanup — ships +green. + +**Recommendation:** Add one store-level test: a `blocks`-only fixture (owner declares +`blocks: [dependent]`, dependent declares nothing), `testHookAfterGraphWrite` failing +after the first write, asserting the dependent's `depends_on` contains the prerequisite, +the owner still carries `blocks`, health is `degraded` not `healthy`, and the rerun +converges. That test fails under the reversed comparison. + +**Resolution:** Added the blocks-only store interruption case: dependent writes +first, owner retains legacy evidence after failure, degraded prefix is sound, +and retry converges. + +#### M1. `task blockers` exposes no derived state for the queried task · **Status:** fixed + +**File:** internal/wire/dependency.go:107-115,143-150 | **Component:** wire/query envelopes, cli/render +**Effort:** S · **Urgency:** soon + +`TaskBlockersEnvelope` and `TaskUnblocksEnvelope` carry `GraphTaskJSON` (id, slug, +status, epic) for the queried task and a full `TaskGraphStateJSON` for every *other* +task in the payload. The queried task's own role, gate, and `eligible` are absent, and +the human header (cli/render/dependency.go:108-116) prints only name, graph health, and +projection. + +`docs/ARCHITECTURE.md` (this branch, line ~155) says "Eligibility is read from derived +state, never inferred from an empty blocker list", and the task's stress-test list says +"authorization must never be inferred from an empty blocker result". The blockers +envelope offers no other signal, so it invites the inference it forbids: + +``` +$ tskflwctl task blockers parked --json +{… "task":{"task_id":"6g0000000d01","slug":"parked","status":"deferred"}, + "projection":"frontier","health":"healthy","blockers":[], …} +$ tskflwctl task blockers parked +✔ no blockers +``` + +`parked` is `deferred`: role `parked`, gate `clear`, `eligible: false`, and correctly +excluded from `task list --unblocked`. Nothing in the blockers answer says so. A +consumer can recover it from `status` plus the role table, but the derived field the +architecture doc points at is not in the payload it points at. + +**Recommendation:** Add `"state": TaskGraphStateJSON` for the queried task to both +envelopes (the data is already in hand — `graph.State(taskID)` is one call in +`Service.TaskBlockers`/`TaskUnblocks`), and render `role/gate` in `graphQueryHeader` +alongside graph health. Both are additive and land inside the existing schema/golden +gates. + +**Resolution:** Both query results and wire envelopes now carry queried-task +state; human headers print role, gate, and eligibility. Wire schema advanced to +1.51. + +#### M2. No product path repairs a broken dependency graph · **Status:** tracked by 6g4g8gatbnrs + +**File:** internal/core/dependency_graph_mutation.go:14-23 | **Component:** core/store, recovery UX +**Effort:** M · **Urgency:** soon + +Once the graph is `broken`, every write path refuses: + +``` +$ tskflwctl task depend remove cyca --on cycb +error: validation failed: repository task graph is broken; repair it before mutation: +dependency cycle: … (2 additional problem(s); run lint for the full sweep) + +$ tskflwctl lint --fix --dry-run +nothing to fix + +$ tskflwctl task set cyca --unset depends_on --force +error: validation failed: depends_on is graph-owned … + +$ tskflwctl task edit cyca # rejects any dependency delta (store/edit.go:175) +``` + +`ValidateTaskGraphMutationSource` rejects the whole repository, not the offending +component, so even a removal that would *repair* the cycle is refused; and a dangling +`depends_on` value cannot be removed at all, because it does not resolve to a task. +`fix.go:349` deliberately skips graph-owned keys, so `lint --fix` cannot help either. +The only remedy is a raw filesystem edit — which the tool otherwise treats as the +non-cooperating writer it defends against. + +Failing closed is the specified behavior and I am not disputing it. The defect is the +guidance: "repair it before mutation … run lint for the full sweep" reads as though a +repair path exists inside the tool, and `lint --fix` then says "nothing to fix". + +**Recommendation:** In this task, fix the message: name the offending file and field +(the `GraphProblem` already carries `Path` and `Field`) and say plainly that graph +repair is a direct file edit today, then rerun `lint`. A guarded repair mode — a removal +that is admitted when it strictly reduces the problem set, or `lint --fix --graph` — is +genuinely separable work and belongs in its own task. + +**Resolution:** Current failures now name the first file and field and state +that direct frontmatter repair plus lint is required. A monotonic guarded repair +path is separately scoped in task 6g4g8gatbnrs. + +#### M3. Generic-path guidance still says the guarded commands are unavailable · **Status:** fixed + +**File:** internal/core/service_task.go:332-336 | **Component:** core/cli messages +**Effort:** XS · **Urgency:** acute + +Four different phrasings answer "you cannot change this here", two of them now false: + +``` +$ tskflwctl task set beta --set depends_on=6g0000000a01 --force +error: … depends_on is graph-owned and cannot be changed with `task set` +(including --force); use guarded dependency operations (`task depend add/remove` once available) + +$ tskflwctl lint # degraded repo + blocked_by: legacy dependency field: … ; canonical migration is intentionally + deferred to guarded dependency operations + +$ tskflwctl task depend add beta --on alpha # degraded repo +error: … 1 legacy dependency field occurrence(s) remain; run the guarded migration + +$ tskflwctl task list --unblocked # degraded repo +error: … 1 legacy dependency field occurrence(s) remain; run `task depend migrate` +``` + +`service_task.go:334` says "once available" and `service.go:401` says "intentionally +deferred" about commands that ship in this very branch. The task's own command contract +requires that `task set` and `task edit` "direct users to `task depend add/remove`"; the +parenthetical actively tells an agent the command does not exist yet. The readiness +audit's M1 raised precisely this ("lint messages cannot cite an exact command name") and +its resolution was accepted. + +Separately, `graphMutationHealthDetail` (dependency_graph_mutation.go:107-119) and +`taskGraphHealthDetail` (dependency_operations.go:437-449) are near-duplicate helpers +that differ only in this sentence — which is how the two spellings drifted. + +**Recommendation:** Name the real commands in all four places, and collapse the two +health-detail helpers into one. `internal/core/setfields_coercion_test.go:121` asserts +only the substring "guarded dependency", so no test pins the stale text. + +**Resolution:** All graph-owned-field, degraded-health, and lint guidance names +the exact supported tskflwctl dependency commands; the duplicate health helper +was consolidated. + +#### L1. Snapshot resolution silently picks a winner where Store resolution reports ambiguity · **Status:** fixed + +**File:** internal/core/dependency_graph.go:752-806 | **Component:** core/graph resolution +**Effort:** XS · **Urgency:** eventually + +`ResolveTaskID` builds one candidate per canonical task ID (line 763, over `g.ids`, +which `newTaskGraph` deduplicates at line 344). `store.resolveID` builds one candidate +per *file*. For two files declaring the same `id:`, the two disagree: + +``` +$ tskflwctl task show 6g0000000a01 +error: "6g0000000a01" matches 2 tasks: one (6g0000000a01), two (6g0000000a01): ambiguous match + +$ tskflwctl task blockers 6g0000000a01 --json +{… "task":{"task_id":"6g0000000a01","slug":"one", …}, "health":"broken", …} +``` + +Acceptance criterion 1 claims parity "for exact IDs/slugs, case-insensitive unique +prefixes, unique substrings, missing values, **and ambiguity**". Every other tier I +tested — exact ID, exact slug, case-insensitive exact, ID prefix, slug prefix, substring, +duplicate slug, unreadable file addressed by ID or by filename slug, `../escape`, empty — +matches the Store byte for byte including the error text. Duplicate ID is the one gap. + +Impact is contained: a duplicate ID makes the graph `broken`, so `ValidateTaskGraphMutationSource` +rejects every mutation before the planner runs. Only the diagnostic queries are affected, +and they report `health: broken` plus the `duplicate-task-id` problem. But the answer is +about an arbitrary one of two files without saying which. + +**Recommendation:** Keep every duplicate-ID entry in the candidate list (the graph already +knows them via `idCounts`/`idPaths`), so the tier returns `ErrAmbiguous` exactly as the +Store does. Add the case to `TestTaskGraphResolveTaskIDMatchesRepositoryReferenceTiers`. + +**Resolution:** TaskGraph retains one resolver candidate per source file, so +duplicate stable IDs remain ambiguous exactly as Store resolution does; +regression coverage added. + +#### L2. Downstream and causal query output is quadratic and unbounded; the stress test does not cover it · **Status:** deferred + +**File:** internal/core/dependency_graph.go:1039-1076 | **Component:** core/graph, wire output size +**Effort:** S · **Urgency:** eventually + +`DownstreamImpact` materializes one full path per reachable dependent and caches it, so a +chain of depth *n* produces Θ(n²) path elements. Measured on a synthetic 1,500-task linear +repository: + +``` +$ /usr/bin/time -l tskflwctl task unblocks 6g0000000000 --json > out.json +0.09 real, 138,608,640 maximum resident set size +$ wc -c < out.json +17232679 # 17.2 MB of JSON for one query +$ tskflwctl task blockers 6g0000001499 --causal --json | wc -c +17267175 +``` + +Latency is fine; the payload is not, for a contract whose whole point is machine +consumption. `TestTaskGraphSupportedDeepChainEnvelope` +(internal/core/dependency_graph_test.go:327-360) builds the 4,096-edge chain but only +calls `State` and `BlockingFrontier`, which return one blocker — so the acceptance +criterion "Deep-chain stress establishes the supported graph-depth envelope" is +established for the cheap projection and not for the two that amplify. Extrapolated, +4,096 would emit roughly 120 MB. + +Real planning graphs are shallow (the production tree's deepest chain is 6), so this is +scale robustness, not a present defect. + +**Recommendation:** Extend the existing stress test to call `DownstreamImpact` and +`CausalBlockers` and assert the measured envelope, so the number is recorded rather than +assumed. Capping or gating path materialization (`--paths=false`) is a reasonable +follow-up if a real repository ever approaches it. + +**Resolution:** A 512-edge test now records the quadratic causal/downstream +full-path amplification while the 4096-edge structural/frontier envelope remains +covered. Output capping is deferred until dogfood exceeds the live depth of six. + +#### L3. Present-but-empty legacy keys are invisible to lint, health, and migrate · **Status:** fixed + +**File:** internal/core/dependency_graph.go:551-553 | **Component:** core/graph, migration +**Effort:** S · **Urgency:** eventually + +`resolveLegacyDiagnostics` skips a field whose value list is empty, so a task carrying +`blocked_by: []` / `blocks: []` produces no diagnostic, keeps the repository `healthy`, +and is never touched by migration: + +``` +$ tskflwctl lint --json +{"schema_version":"1.50","unreadable":[],"issues":[]} +$ tskflwctl task depend migrate --json +{… "changed":false, "cleared_legacy_fields":[], …} +$ grep blocked_by planning/tasks/6g0000000a01-alpha.md +blocked_by: [] +``` + +ADR-0006 §2 says implementation must "converge on `depends_on` alone"; an empty legacy +key survives forever, and a later hand edit that fills it in reintroduces legacy +vocabulary. There is no correctness consequence today (`task set` and `task edit` both +refuse to write the key), so this is completeness, not a bug. + +**Recommendation:** Either treat a present-but-empty legacy key as a clearable occurrence +(needs a parser signal distinguishing "absent" from "empty"), or state the carve-out in +the migration's documentation. Reasonable as a follow-up rather than current scope. + +**Resolution:** Parser-side field-presence metadata makes empty or null legacy +keys degraded lint/migration occurrences instead of invisible content. + +#### L4. Migration receipts under-report cleared legacy fields · **Status:** fixed + +**File:** internal/core/dependency_operations.go:243-245 | **Component:** core/receipts +**Effort:** XS · **Urgency:** eventually + +`ClearedLegacyFields` is built from the legacy *diagnostics*, but the materializer clears +all three legacy keys whenever `ClearLegacy` is set (store/graphmutation.go:150-154). A +task with one diagnosed field and one empty legacy key loses both and reports one: + +``` +# alpha declares blocks: [beta] and dependencies: [] +$ tskflwctl task depend migrate --json | jq '.cleared_legacy_fields[] | select(.task_id=="6g0000000a01")' +{"task_id":"6g0000000a01","field":"blocks"} +# file afterwards: both keys gone +``` + +The task's contract says migration receipts "identify planned, applied, skipped, and +remaining work"; the cleared-field list is the one place the receipt is not a faithful +description of the write. + +**Recommendation:** Either restrict `ClearLegacy` to the fields that actually had +diagnostics, or derive `ClearedLegacyFields` from the materialized delta. The first is +smaller and keeps the planner pure. + +**Resolution:** Every present legacy key now produces a diagnostic, so migration +receipts list every key the materializer removes, including empty companions; +store regression coverage added. + +#### L5. Mutation receipts omit the resulting derived state, so a new edge can silently strand a task · **Status:** tracked by 6g3q4rte8kc1 + +**File:** internal/cli/render/dependency.go:22-33 | **Component:** cli/receipts +**Effort:** S · **Urgency:** eventually + +`task depend add` will happily point a live task at a `deprecated` prerequisite. ADR-0006 +says withdrawn tasks "never satisfy downstream dependencies", so the dependent's gate +becomes permanently broken — reported with a green tick and no hint: + +``` +$ tskflwctl task depend add live --on dead +✔ added 6g0000000a01 -> 6g0000000b02 +applied task files: 6g0000000b02 + +$ tskflwctl task blockers live --json | jq '.blockers[] | {id:.task.task_id, reason}' +{"id":"6g0000000a01","reason":"withdrawn"} +$ tskflwctl task list --unblocked --json +{"schema_version":"1.50","tasks":[]} +``` + +The graph stays `healthy` — this is a legal edge, not a defect — but the receipt is the +moment to say what just happened. The same applies to adding a not-yet-complete +prerequisite to a `completed` task, which makes it `inconsistent`. + +**Recommendation:** Include the dependent's post-plan `TaskGraphState` in the mutation +receipt (the planner already holds the prospective graph via +`ValidateTaskGraphMutationPlan`), or at minimum warn in the human receipt when the +planned edge leaves the dependent's gate non-clear. Separable from the current slice; a +good pairing with M1's envelope change. + +**Resolution:** The eligibility/lifecycle slice now owns a reusable before-after +graph-state impact shape and post-plan state in dependency add/remove receipts. + +#### L6. Graph queries print every repository problem, repeated once per cycle member · **Status:** fixed + +**File:** internal/core/dependency_operations.go:376-377,412 | **Component:** core/queries, cli/render +**Effort:** XS · **Urgency:** eventually + +`TaskBlockers`/`TaskUnblocks` attach `graph.Problems()` — the whole repository sweep — +to a single-task query, and `analyzeDAG` emits one `ProblemCycle` per SCC member with an +identical message, so the human renderer repeats it: + +``` +$ tskflwctl task blockers cyca +⚠ cycle: dependency cycle: 6g0000000a01 -> 6g0000000a02 -> 6g0000000a01 +⚠ cycle: dependency cycle: 6g0000000a01 -> 6g0000000a02 -> 6g0000000a01 +⚠ missing-dependency: task 6g0000000a03 depends on missing task 6g0000000zzz +``` + +The third line concerns a task unrelated to the query. `TaskGraph.LocalProblems` exists +for exactly this and has no production caller. Reporting the full sweep is a defensible +choice for a diagnostic read — a 20-member SCC producing 20 identical lines is not. + +**Recommendation:** Deduplicate identical cycle problems in the human renderer, and +consider ordering the queried task's local problems first. Follow-up. + +**Resolution:** Human graph diagnostics deduplicate identical code-and-message +entries with regression coverage. Structured machine output intentionally +retains the attributable repository-wide sweep. + +#### L7. The new list-returning query has no `-o`/`-q` output mode · **Status:** deferred + +**File:** internal/cli/task_dependency.go:130-149 | **Component:** cli ergonomics +**Effort:** XS · **Urgency:** eventually + +`task unblocks` returns a list but offers only `--json`, while `task list` offers +`-o human|json|name|table|csv` and `-q`. The README's own idiom is +`tskflwctl task list -q --tag tui | xargs tskflwctl task start`; the natural +`task unblocks -q | xargs …` is unavailable. `task blockers` has the same shape. + +**Recommendation:** Add `-q` (ids one per line) to both, reusing the existing name-mode +plumbing. Follow-up; no correctness impact. + +**Resolution:** No correctness or current dogfood workflow requires terse output +yet; revisit after real blockers/unblocks scripting demonstrates the desired ID +versus slug contract. + +## Rejected concerns + +Investigated deliberately and found sound. Recorded so a later reviewer does not repeat +the work. + +- **Resolver parity.** Tested `add-retry` (ambiguous), `ADD-RETRY-ALPHA` (case-insensitive + exact slug), `6g0000000a` and `6g0000000a0` (ambiguous ID prefixes), `retry-alpha` + (substring), `6G0000000A01` (case-insensitive exact ID), duplicate slug `dup`, + `../zeta`, `""`, an unreadable file by ID and by filename slug. `task show` and + `task blockers` produce identical results and identical error strings in every case + except L1. +- **No Store re-entry, no pre-lock TOCTOU.** Planners receive only `*TaskGraph`; + `ResolveTaskID` is pure; `MutateTaskGraph` re-reads and whole-snapshot-CASes + (`SameSourceSnapshot`) before the first write and per-file-CASes before each later one. +- **Concurrent opposite edges, real processes.** Two `tskflwctl task depend add` + processes racing `A→B` and `B→A`: one exit 0, one exit 11 with + `planned write prefix … would leave a broken graph: dependency cycle …`, final graph + acyclic with exactly one edge. +- **Interrupted migration recovery.** Forced a genuine `EPERM` on the second rename. + Result: durable prefix `["6g0000000b02"]`, remaining `["6g0000000a01"]`, both present in + `error.dependency_mutation` with workspace identity; human text "durable dependency + prefix applied to … retry the same command to converge remaining tasks …"; graph + degraded not broken; no `.tskflwctl-*.tmp` left behind; rerun converged to healthy. + The all-applied variant produces the distinct "all planned dependency task files were + durably applied" wording. +- **Idempotence and the clock.** `task depend add` of a present edge and `remove` of an + absent edge are byte-identical (`bytes.Equal` asserted in + `internal/cli/task_dependency_test.go:97,126`) and do not advance `updated_at`; + `materializeTaskGraphPlan` compares content *before* stamping, so the stamp cannot be + the only change. `s.now()` is taken once per operation, outside the retry loop. +- **`task unblocks` excludes its source even in a cycle.** `seen` is seeded with the + queried ID (dependency_graph.go:1046), so it can never be re-enqueued; + `TestTaskGraphCycleBlockerReason` pins it. +- **`task list --unblocked` fails closed.** Errors with `ErrValidation` on both `degraded` + and `broken`, and `State().Eligible` independently requires `health == healthy`, so the + filter is doubly gated. +- **Generic paths cannot bypass graph ownership.** `core.SetFields` (service_task.go:273, + before the `--force` branch), `store.FS.SetFields` (fsstore.go:281), `CreateTask`, + `EditTask`'s sorted before/after comparison including the malformed-frontmatter carve + (edit.go:160-177), and `fix.go:349` all refuse. Reordering `depends_on` in the editor is + correctly still allowed; duplicating an entry is not. +- **Nil-versus-empty and schema fidelity.** Every new array is built with + `make(…, 0, n)` / `append([]string{}, …)`, so empty renders `[]`, never `null`; all are + `required` in the reflected schema. `envelopes_test.go` validates a `broken` blockers + envelope carrying problems and legacy diagnostics against the schema. `GraphProblemJSON.Cycle` + is the only `omitempty` array and is genuinely optional. +- **Contract churn.** All 22 modified goldens change only `schema_version` 1.49→1.50; + three new goldens added; `go run ./internal/tools/docgen` reproduces `docs/cli/` + byte-identically; `go mod tidy -diff` clean; `git diff --check` clean. +- **Boundary hygiene.** No graph-library type crosses core/CLI/wire; the analyzer is + taskflow-owned; `TaskDependencyWrite` names only task IDs and a canonical set. +- **Absolute paths in `problems[].path`.** Matches the existing envelope convention + (`PathEnvelope`, `domain.FileProblem`), not new leakage. +- **Migration semantics on the real tree.** Six legacy `blocked_by` occurrences became + eight canonical edges with direction preserved: `6ffr4wc01gtv→6fgq1n00235z`, + `6fgq1n0006y3→{6fgq1n000pca,6fgq1n0016kj,6fgq1n003wty}`, + `6fgq1n000pca/6fgq1n0016kj/6fgq1n003wty→6fgq1n002skz`, `6fgq1n00235z→6fgq1n0006y3`. + Re-running `task depend migrate --dry-run` on the live tree now reports + `changed:false`. The only remaining `dependencies:` matches in `planning/` are inside a + fenced manifest example in the spike task's body. +- **Bootstrap sequence direction.** `6g3q4rt7mgjn` depends on both completed foundations; + the five later slices form one chain, each depending on its predecessor. `task unblocks + 6g3q4rt7mgjn` returns all five with correct shortest paths and `queued/blocked` state. +- **Already-canonical and doubly-declared legacy edges.** A `blocks` declaration whose + canonical edge already exists produces an `outcome:"skipped"` edge and only the + clear-owner is written; a task declaring the same edge through `blocked_by` *and* + `dependencies` produces one edge and two cleared fields. +- **Lock-held validation cost.** `ValidateTaskGraphMutationPlan` rebuilds the whole graph + per planned write, i.e. O(k·(V+E)) under the repository lock. Negligible at k≤8 today, + and the 2026-08-26 ADR amendment already commits to benchmarking this before the + bulk-linking slice; not re-raised here. +- **Duplicate `--on` operands.** `--on tb --on 6g000000000b` is a hard validation error + rather than a dedup. That is the specified behaviour ("validate … duplicate/self/missing + edges") and reads correctly. + +## Traceability against acceptance criteria + +| # | Criterion (abbreviated) | Verdict | Evidence | +|---|---|---|---| +| 1 | Snapshot-local resolution matches ordinary resolution, no Store call | **Partial** | Byte-identical across 10 reference shapes; duplicate-ID ambiguity diverges (L1) | +| 2 | Add/remove validate endpoints, duplicate/self/missing, union, cycles, prefixes, final health | **Met** | Self/duplicate/ambiguous/cycle all rejected; concurrent opposite edges refused | +| 3 | Present adds and absent removals are receipt-bearing no-ops, no byte or timestamp change | **Met** | `bytes.Equal` assertions plus reproduced by hand | +| 4 | `--dry-run` runs the same resolution/planning/validation with zero replacements | **Met** | Dry-run receipt carries plan; no file written; degraded/broken still rejected | +| 5 | `migrate` converts the six live legacy occurrences, preserving frontmatter/body | **Met** | Real tree: 6 occurrences → 8 edges; comments and bodies preserved; rerun is a no-op | +| 6 | Migration graph-sound after every injected write failure; retry converges | **Partial** | True in behaviour; the load-bearing `blocks` ordering case is untested (H2) | +| 7 | Receipts distinguish applied/skipped; partial-failure diagnostics preserve applied and remaining | **Partial** | Applied/remaining correct and structured; cleared-field list under-reports (L4) | +| 8 | `blockers` defaults to frontier, `--causal` full closure, deterministic reason/path/health | **Partial** | Correct on healthy graphs; both projections empty on degraded (H1) | +| 9 | `unblocks` reports all transitive dependents without counterfactual claims | **Partial** | Correct and non-counterfactual on healthy graphs; misses legacy edges on degraded (H1) | +| 10 | `task list --unblocked` filters derived eligibility, no work on an unsound graph | **Met** | Fails closed on degraded and broken; `Eligible` independently gated on health | +| 11 | `task set --force` and `task edit` cannot bypass graph ownership | **Met** | Rejected on both spellings, both paths, plus `CreateTask` and `lint --fix` | +| 12 | Envelopes enter the reflected schema, human/JSON parity, no graph-library types | **Met** | Schema `$defs` present and `required`; broken-state envelope schema-validated | +| 13 | Semantic changes use the Service clock exactly once; no-ops do not advance `updated_at` | **Met** | Clock taken once outside the retry loop; content compared before stamping | +| 14 | Deep-chain stress establishes the supported graph-depth envelope | **Partial** | 4,096 chain covers `State`/`BlockingFrontier` only; the amplifying queries are unmeasured (L2) | +| 15 | Production commands persist bootstrap edges and exercise the queries on the real graph | **Met** | Bootstrap chain correct; live `blockers`/`unblocks`/`--unblocked` all verified | + +## Validation commands and results + +``` +GOCACHE=/tmp/taskflow-review-go-cache go test ./... + → ok, 23 packages, 0 failures + +GOCACHE=/tmp/taskflow-review-go-cache go test -race ./... + → ok, 23 packages, 0 failures, 0 data races + +GOLANGCI_LINT_CACHE=/tmp/taskflow-review-lint-cache golangci-lint run ./... + → 0 issues + +git diff --check + → clean + +go mod tidy -diff + → clean + +go run ./internal/tools/docgen -out /tmp/docgen-check && diff -rq /tmp/docgen-check docs/cli + → identical (generated CLI reference in sync) + +tskflwctl task depend migrate --dry-run --json # real planning tree, read-only + → changed:false, no legacy occurrences remain + +mutation test: internal/core/dependency_operations.go:323 `>` → `<` (copy outside the repo) + → go test ./... ok, 23 packages, 0 failures ← the reversal is undetected (H2) +``` + +## Candidate tasks + +Disposition after implementation hardening: + +- ✅ `6g4g8gatbnrs` — guarded repair of an already-broken graph is separately scoped with a monotonic problem-reduction proof (M2). +- ✅ Resolved legacy projection and empty-key convergence landed in the reviewed slice rather than creating a redundant follow-up (H1, L3, L4). +- ✅ `6g3q4rte8kc1` now owns the shared before/after graph-state receipt shape and post-dependency-mutation state warning (L5). +- ✅ Repeated human cycle lines are deduplicated here; repository-wide structured problems remain deliberate. Terse query output is deferred until dogfooding establishes the needed ID/slug contract, so no speculative task was created (L6, L7). diff --git a/planning/tasks/6ffr4wc01gtv-color-and-design-overhaul-one-coherent-palette-across-every-surface.md b/planning/tasks/6ffr4wc01gtv-color-and-design-overhaul-one-coherent-palette-across-every-surface.md index 82269f97..37e673d2 100644 --- a/planning/tasks/6ffr4wc01gtv-color-and-design-overhaul-one-coherent-palette-across-every-surface.md +++ b/planning/tasks/6ffr4wc01gtv-color-and-design-overhaul-one-coherent-palette-across-every-surface.md @@ -9,10 +9,10 @@ priority: medium autonomy_level: 3 tags: [cli, tui] created: "2026-06-25" -updated_at: "2026-06-29" -blocked_by: [theme-discovery-commands-glamour-polish-and-a-second-theme] +updated_at: "2026-08-27" completed_at: "2026-06-29" id: 6ffr4wc01gtv +depends_on: [6fgq1n00235z] --- # Color/design overhaul: one coherent palette across every surface diff --git a/planning/tasks/6fgq1n0006y3-theme-config-table-and-selection-plumbing.md b/planning/tasks/6fgq1n0006y3-theme-config-table-and-selection-plumbing.md index 0d360ee4..067578c0 100644 --- a/planning/tasks/6fgq1n0006y3-theme-config-table-and-selection-plumbing.md +++ b/planning/tasks/6fgq1n0006y3-theme-config-table-and-selection-plumbing.md @@ -9,11 +9,11 @@ priority: medium autonomy_level: 3 tags: [cli, tui, design] created: "2026-06-28" -blocked_by: [route-tui-chrome-through-the-palette, route-progress-bars-and-the-cli-ansi-map-through-the-palette, route-the-interactive-picker-theme-through-the-palette] -updated_at: "2026-06-29" +updated_at: "2026-08-27" started_at: "2026-06-29" completed_at: "2026-06-29" id: 6fgq1n0006y3 +depends_on: [6fgq1n000pca, 6fgq1n0016kj, 6fgq1n003wty] --- ## Objective Let users select a theme via config/env/flag, and feed it to every routed surface. diff --git a/planning/tasks/6fgq1n000pca-route-the-interactive-picker-theme-through-the-palette.md b/planning/tasks/6fgq1n000pca-route-the-interactive-picker-theme-through-the-palette.md index e605bd83..243d076d 100644 --- a/planning/tasks/6fgq1n000pca-route-the-interactive-picker-theme-through-the-palette.md +++ b/planning/tasks/6fgq1n000pca-route-the-interactive-picker-theme-through-the-palette.md @@ -9,11 +9,11 @@ priority: medium autonomy_level: 3 tags: [cli, design] created: "2026-06-28" -blocked_by: [design-package-foundation-palette-theme-registry-and-the-neon-default] -updated_at: "2026-06-29" +updated_at: "2026-08-27" started_at: "2026-06-28" completed_at: "2026-06-29" id: 6fgq1n000pca +depends_on: [6fgq1n002skz] --- ## Objective Replace the picker's hardcoded `#b026ff` stopgap with the palette. diff --git a/planning/tasks/6fgq1n0016kj-route-tui-chrome-through-the-palette.md b/planning/tasks/6fgq1n0016kj-route-tui-chrome-through-the-palette.md index 64d5f49b..bb19cff6 100644 --- a/planning/tasks/6fgq1n0016kj-route-tui-chrome-through-the-palette.md +++ b/planning/tasks/6fgq1n0016kj-route-tui-chrome-through-the-palette.md @@ -9,11 +9,11 @@ priority: medium autonomy_level: 3 tags: [tui, design] created: "2026-06-28" -blocked_by: [design-package-foundation-palette-theme-registry-and-the-neon-default] -updated_at: "2026-06-28" +updated_at: "2026-08-27" started_at: "2026-06-28" completed_at: "2026-06-28" id: 6fgq1n0016kj +depends_on: [6fgq1n002skz] --- ## Objective Make the TUI's structural chrome derive from the palette instead of scattered lipgloss literals. diff --git a/planning/tasks/6fgq1n00235z-theme-discovery-commands-glamour-polish-and-a-second-theme.md b/planning/tasks/6fgq1n00235z-theme-discovery-commands-glamour-polish-and-a-second-theme.md index 74450d67..6e16e689 100644 --- a/planning/tasks/6fgq1n00235z-theme-discovery-commands-glamour-polish-and-a-second-theme.md +++ b/planning/tasks/6fgq1n00235z-theme-discovery-commands-glamour-polish-and-a-second-theme.md @@ -9,11 +9,11 @@ priority: medium autonomy_level: 3 tags: [cli, tui, design] created: "2026-06-28" -updated_at: "2026-06-29" -blocked_by: [theme-config-table-and-selection-plumbing] +updated_at: "2026-08-27" started_at: "2026-06-29" completed_at: "2026-06-29" id: 6fgq1n00235z +depends_on: [6fgq1n0006y3] --- ## Objective Make themes discoverable and prove the registry with a second theme + the deferred polish. diff --git a/planning/tasks/6fgq1n003wty-route-progress-bars-and-the-cli-ansi-map-through-the-palette.md b/planning/tasks/6fgq1n003wty-route-progress-bars-and-the-cli-ansi-map-through-the-palette.md index feb0226b..b1468724 100644 --- a/planning/tasks/6fgq1n003wty-route-progress-bars-and-the-cli-ansi-map-through-the-palette.md +++ b/planning/tasks/6fgq1n003wty-route-progress-bars-and-the-cli-ansi-map-through-the-palette.md @@ -9,11 +9,11 @@ priority: medium autonomy_level: 3 tags: [cli, tui, design] created: "2026-06-28" -blocked_by: [design-package-foundation-palette-theme-registry-and-the-neon-default] -updated_at: "2026-06-29" +updated_at: "2026-08-27" started_at: "2026-06-28" completed_at: "2026-06-29" id: 6fgq1n003wty +depends_on: [6fgq1n002skz] --- ## Objective Route the rollup/segmented bars and the CLI's 16-color ANSI map through the palette, with no porcelain churn. diff --git a/planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md b/planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md index 3313ff26..a8d525b4 100644 --- a/planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md +++ b/planning/tasks/6g3q4rt7mgjn-ship-guarded-dependency-mutations-and-graph-queries.md @@ -1,66 +1,108 @@ --- schema: 1 id: 6g3q4rt7mgjn -status: next-up +status: completed epic: 30-threads-and-task-dependency-graphs -description: Expose safe repository-global dependency changes, blockers, downstream impact, dry-run, and deterministic explanatory plans. -effort: 3-5 days +description: Expose safe repository-global dependency changes, migration, blockers, downstream impact, and deterministic explanatory graph queries. +effort: 5-8 days tier: 1 priority: high autonomy_level: 3 tags: [threads, graph, cli, storage] created: "2026-08-25" -updated_at: "2026-08-27" +updated_at: "2026-08-28" +depends_on: [6g3q4rst78qy, 6g3q4rt0wzkq] +started_at: "2026-08-27" +completed_at: "2026-08-28" --- # Ship guarded dependency mutations and graph queries ## Objective -Expose safe repository-global dependency operations and deterministic read queries over the strict graph foundation. +Expose safe repository-global dependency operations and deterministic explanatory queries over the strict graph foundation. ## Scope -- Add `task depend add/remove`, blocker, downstream-impact, and explanatory plan use cases and CLI surfaces. -- Perform exact-ID resolution, duplicate/self/missing checks, cycle validation, dry-run, and write inside the repository mutation guard. -- Migrate diagnosed legacy dependency fields by resolving slugs to stable IDs and applying the validated union inside the same guard. -- Treat `task edit` as a guarded product path for dependency deltas, or reject the delta with direction to the specialized commands. -- Return stable human and machine receipts that make the global blast radius explicit. +- Add guarded `task depend add/remove` operations and the repository-wide `task depend migrate` convergence command. +- Resolve every user task reference inside the authoritative immutable graph snapshot, returning canonical stable IDs while preserving Taskflow's ordinary exact, prefix, and substring matching policy. +- Add `task blockers` with action-frontier and causal-closure projections, `task unblocks` as a downstream-impact query, and `task list --unblocked` as a fail-closed eligibility selector. +- Reuse the existing taskflow-owned topological analysis for later Thread projections; do not add a repository-global `task plan` command in this slice. +- Keep dependency deltas out of generic set/edit paths and return stable human and machine receipts that expose graph health, workspace identity, no-ops, and resumable partial application. + +## Command contracts + +- `task depend add --on ...` and `task depend remove --on ...` resolve all operands from the guarded snapshot. Adds and removes are set operations: already-present adds and absent removes succeed as explicit idempotent skips. +- `task depend migrate` converts every exactly resolvable legacy `blocked_by`, `dependencies`, and `blocks` occurrence into the canonical `depends_on` union and clears the legacy fields. Present-but-empty legacy keys are occurrences too: lint reports them and migration removes and receipts them. The command is repository-wide in V1, has no per-task selector, and inherits global `--dry-run` and `--json` behavior. +- `task blockers ` defaults to the action-oriented frontier. `--causal` selects the full forensic closure, and machine output names the chosen projection. +- `task unblocks ` returns every transitive downstream dependent with current lifecycle role, gate, eligibility, and direct/transitive attribution. It reports impact, not a counterfactual promise that completing the source immediately makes every result eligible. +- `task list --unblocked` selects only derived `Eligible` tasks. An unsound relevant graph returns no eligible work together with an explicit diagnosis. +- `task set`, including `--force`, and `task edit` reject canonical or legacy dependency deltas and direct users to `task depend add/remove`. V1 does not reinterpret arbitrary editor-produced graph changes. + +## Reference-resolution contract + +The guarded planner receives only `*TaskGraph`, so resolution is a pure snapshot operation. It returns one canonical stable ID using the same tiers as ordinary task commands: exact ID or slug, then unique case-insensitive prefix, then unique case-insensitive substring. Missing and ambiguous inputs retain their typed errors and deterministic candidate details. Share or extract the existing matching policy, or pin exact parity with common contract tests; Store resolution before or during the callback is not acceptable. + +## Mutation and recovery contract + +- The Store owns the canonical-root guard, strict snapshot load, pure planner invocation, source/plan/prefix/final validation, surgical materialization, whole-snapshot and per-target CAS, and atomic per-file replacement. +- Planners use only supplied immutable graph values, emit deterministic prefix-safe write order, and never call Store or Service. Every durable prefix and the final state must remain sound. +- A semantic change receives the Service clock and stamps `updated_at` exactly once. Idempotent skips remain byte-identical. Dry-run performs the same authoritative planning and validation without writing. +- Migration is serialized but is not an all-files rollback transaction. A later apply or CAS failure may leave the sound durable prefix reported by the guard; retry rebuilds from the current snapshot and converges. +- Edge receipts identify canonical dependent/prerequisite IDs and per-edge applied or skipped outcomes, plus `changed`, `dry_run`, and workspace identity. Migration receipts additionally identify planned, applied, skipped, and remaining work. +- A failure after a durable prefix exposes applied task IDs and remaining resumable work through typed errors and structured JSON diagnostics. The existing prose-only generic error payload is insufficient for this case. + +## Query and wire contract + +- Blocker entries distinguish direct and transitive constraints and carry a stable reason plus one deterministic shortest path. +- Diagnostic reads project canonical and exactly resolved legacy edges, so migration preserves the blocker/downstream meaning of an unchanged repository. They return deterministic results accompanied by graph health and taskflow-owned structured problems; mutations and eligibility selectors still fail closed unless the graph is healthy. +- Machine output contains stable task IDs, the queried task's derived role/gate/eligibility, current derived state for returned tasks, projection names, workspace identity on mutations, and attributable validation/conflict/recovery details. No graph-library type crosses the core, CLI, or wire boundary. +- New envelopes participate in the reflected JSON Schema, schema comments, golden output, and human/JSON parity tests. Exact Go DTO names are implementation details rather than ADR contracts. +- Machine-readable authoring schema continues to mark every graph-owned dependency field unavailable to generic set/unset and directs callers to guarded dependency commands. ## Acceptance criteria -- [ ] Add/remove are additive/removal-aware; an already-present add is an idempotent skip with a receipt entry, and invalid unions never write. -- [ ] `--dry-run` performs the same authoritative validation without mutation. -- [ ] Blocker, unblocks, and topology results are deterministic; blocker entries distinguish direct/transitive work and carry a reason plus one deterministic shortest path. -- [ ] Machine output contains stable task IDs and attributable validation/conflict errors without graph-library types. -- [ ] Diagnostic queries degrade with explicit problems and graph health; frontier/unblocked selectors return no eligible work on an unsound relevant graph; mutation fails closed. -- [ ] The six live legacy `blocked_by` values migrate from resolvable slugs to stable `depends_on` IDs with atomic-frontmatter/body preservation; missing or ambiguous values write nothing. -- [ ] `task set` cannot mutate `depends_on` even with `--force`, and `task edit` cannot bypass guarded graph validation. -- [ ] Public blocker commands expose separately named causal-closure and - action-frontier projections, while every authorization path uses derived - eligibility rather than blocker-list emptiness. -- [ ] Machine-readable schema marks graph-owned dependency fields as unavailable - to generic set/unset and directs callers to guarded dependency operations. -- [ ] Deep-chain stress establishes a supported graph-depth envelope; replace - recursive sound derivation if measured repository shapes approach unsafe stack - or latency bounds. -- [ ] Planner callbacks resolve task IDs and legacy references only from - supplied immutable graph values, emit deterministic prefix-safe write order, - and never call a Store method. -- [ ] Every semantic dependency change receives the Service clock through the - mutation port, stamps updated_at exactly once, and an idempotent skip does not - advance it. -- [ ] task edit keeps the human editor session outside the repository guard and - either rejects a dependency delta with direction or reapplies it through the - guarded dependency use case after fresh validation. +- [x] Snapshot-local reference resolution matches ordinary task resolution for exact IDs/slugs, case-insensitive unique prefixes, unique substrings, missing values, and ambiguity without calling Store. +- [x] Add/remove validate exact canonical endpoints, duplicate/self/missing edges, the proposed union, cycles, every durable prefix, and final graph health before writing. +- [x] Already-present adds and absent removals are successful receipt-bearing no-ops with no byte or timestamp change. +- [x] `--dry-run` executes the same authoritative resolution, planning, and validation with zero replacements. +- [x] `task depend migrate` converts the six live resolvable legacy occurrences with frontmatter/body preservation; unsafe resolution or projected graph state writes nothing. +- [x] Migration remains graph-sound after every injected write failure and retry converges from every durable prefix. +- [x] Human and machine mutation receipts distinguish applied and skipped edges; partial-failure diagnostics preserve applied and remaining migration work. +- [x] `task blockers` defaults to action frontier, `--causal` returns full closure, and both expose deterministic reason/path/directness data with explicit graph health. +- [x] `task unblocks` deterministically reports all transitive downstream dependents and current derived state without claiming counterfactual eligibility. +- [x] `task list --unblocked` filters on derived eligibility and returns no dispatchable work on an unsound relevant graph. +- [x] `task set --force` and `task edit` cannot bypass graph ownership or guarded dependency validation. +- [x] Query and mutation JSON envelopes enter the reflected schema and preserve human/machine semantic parity without graph-library types. +- [x] Semantic changes use the Service clock exactly once; no-ops do not advance `updated_at`. +- [x] Deep-chain stress establishes the supported 4,096-edge structural/frontier envelope; a separate 512-edge test records the quadratic full-path output amplification for causal/downstream queries so any future cap is evidence-driven. +- [x] Production commands persist this epic's bootstrap dependency edges and exercise blockers, unblocks, and unblocked selection against the real planning graph. ## Stress tests -- Concurrent opposite edges, duplicate adds, absent removals, stale content, malformed repositories, and deep/wide/disconnected graphs. -- Direct dependency commands racing another direct command and a future bulk-apply-shaped writer. -- Concurrent-cycle errors retain validation semantics while explaining that repository state changed after the command began when attributable. +- Concurrent opposite edges, duplicate adds, absent removals, stale source content, malformed repositories, and direct dependency commands racing another command or a future bulk-shaped writer. +- Exact resolver parity across ordinary and guarded paths, including duplicate slugs, case, prefix/substring tiers, and deterministic ambiguity. +- Legacy migration failure after every write prefix, idempotent retry, body/custom-frontmatter preservation, and structured partial-failure output. +- Healthy, degraded, broken, deep, wide, disconnected, reconvergent, and cyclic query fixtures; authorization must never be inferred from an empty blocker result. +- Human, JSON, schema, error-classification, and `task edit` rejection coverage. + +## Sequencing and dogfood gate + +Requires the strict read foundation and portable mutation guard. Once released, use the production migration command for the six legacy occurrences, persist the epic bootstrap edges through `task depend add`, and use the production query commands while implementing every remaining Threads task. This is the first dependency-DAG dogfood surface; public Thread planning remains in the later Thread entity slice. + +## Readiness review disposition (2026-08-27) + +The focused Gemini audit is accepted in substance. Its resolver, migration-command, query-default, unblocked-selector, and receipt concerns are owned here rather than split into separate tasks. Its illustrative DTO field lists are not frozen, and its all-files atomicity language is narrowed to the guard's actual sound-prefix and resumable-retry contract. The expanded command, migration, wire, and recovery surface raises the estimate from 3-5 to 5-8 days. + +## Implementation review gate (2026-08-28) + +The guarded add/remove/migrate commands, explanatory blocker/downstream queries, fail-closed unblocked selector, typed recovery receipts, wire envelopes, and generated command documentation are implemented on `feat/guarded-dependency-operations`. The real planning tree was migrated from all six legacy field occurrences to eight canonical edges, then a second migration returned a byte-preserving no-op. Production commands also persisted this epic's bootstrap sequence: the current slice depends on both completed foundations and the five later slices form one downstream chain. + +Tests cover resolver parity, idempotent byte preservation, cycle refusal, every durable prefix of a multi-file migration, convergent retry, raw content preservation, healthy/degraded/broken reads, human/JSON parity, structured failure recovery, a complete 4,096-edge structural/frontier chain, and a measured 512-edge full-path amplification envelope. `go test ./...`, `go test -race ./...`, golangci-lint, module tidiness, reflected JSON Schema validation, generated CLI docs, planning lint, and diff hygiene were clean before independent review; the review amendments below must pass the same gates before closeout. + +## Adversarial implementation review disposition (2026-08-28) -## Sequencing +The Claude audit found the meaningful gaps. Resolved legacy edges now participate in blocker, downstream, sound-completion, and gate projections instead of only cycle analysis; queried-task state is explicit in human and JSON output; a `blocks`-only interruption test mutation-proofs the dependent-first migration order; duplicate IDs remain ambiguous inside the snapshot resolver; empty legacy keys are diagnosed, migrated, and receipted; human cycle diagnostics are deduplicated; and stale recovery/command guidance names the exact supported commands. Full-path query amplification remains intentionally uncapped, but its quadratic shape is now recorded by a bounded test because the live graph depth is six. -Requires the strict read foundation and portable mutation guard. Its production commands persist the epic's bootstrap edges and become the first dependency dogfood surface for the remaining tasks. +Repairing an already-broken dependency graph is intentionally not smuggled into this task's ordinary mutation guard; it is tracked by `6g4g8gatbnrs`. Post-dependency-mutation state impact is tracked in the lifecycle/eligibility follow-up `6g3q4rte8kc1`, where the shared before/after graph-state receipt shape belongs. Terse `-q` query modes are deferred until dogfooding demonstrates a concrete workflow rather than added speculatively. -## Mutation-guard integration amendment (2026-08-27)\n\nThe production mutation callback is snapshot-only. Exact-ID lookup, duplicate/self/missing checks, legacy resolution, and write ordering must be derived from the supplied immutable TaskGraph and pure core validators; callback code cannot reach back into Store or Service. Use the named LoadTaskGraph source for query loading and do not restore the unused ReadTaskGraph service seam.\n\nThe Service passes its clock into MutateTaskGraph. A real semantic change is stamped by store materialization, while an already-satisfied add or remove stays byte-identical. The six-task legacy migration must emit one deterministic prefix-safe sequence even though stable-ID order happens to be safe for its projected-edge replacement shape.\n\nHuman editing remains outside the repository guard. After the editor returns, task edit compares the proposed dependency set and either rejects it with exact task depend guidance or invokes the same guarded dependency use case against a fresh snapshot; the editor process is never held inside callback exclusion. +Both independent implementation audits are closed with every finding fixed, tracked, or explicitly deferred. Post-review validation is clean: all package tests and the full race suite pass, golangci-lint reports zero issues, module tidiness and diff hygiene are clean, reflected schema/goldens/generated CLI docs are synchronized, and ordinary plus audit planning lint report no issues. diff --git a/planning/tasks/6g3q4rte8kc1-enforce-dependency-eligibility-across-every-task-start-path.md b/planning/tasks/6g3q4rte8kc1-enforce-dependency-eligibility-across-every-task-start-path.md index f96f5955..0690ba13 100644 --- a/planning/tasks/6g3q4rte8kc1-enforce-dependency-eligibility-across-every-task-start-path.md +++ b/planning/tasks/6g3q4rte8kc1-enforce-dependency-eligibility-across-every-task-start-path.md @@ -11,6 +11,7 @@ autonomy_level: 2 tags: [threads, graph, lifecycle, cli] created: "2026-08-25" updated_at: "2026-08-27" +depends_on: [6g3q4rt7mgjn] --- # Enforce dependency eligibility across every task start path @@ -24,6 +25,7 @@ Make dependency eligibility one authoritative core policy for all transitions in - Route `task start`, generic move, create-and-start, accepted `task edit` status deltas, and reusable adapter/TUI entry points through the same guard. - Replace the ambiguous internal force boolean with typed gate overrides while retaining contextual CLI `--force` spelling. - Report descendant tasks whose derived gate state changes after a lifecycle transition; add affected Thread IDs when Thread persistence exists. +- Define one reusable before/after graph-state impact shape and add post-plan state for directly affected dependents to guarded dependency mutation receipts, so a legal edge to a withdrawn or unfinished prerequisite cannot look consequence-free. ## Acceptance criteria @@ -33,6 +35,7 @@ Make dependency eligibility one authoritative core policy for all transitions in - [ ] Reopening an upstream task makes completed descendants unsound without rewriting their persisted statuses. - [ ] Deferred and deprecated prerequisites follow ADR-0006 semantics consistently. - [ ] Lifecycle receipts report descendant task IDs/counts whose gate state changed and, after Thread support lands, affected Thread IDs with an explanatory remedy. +- [ ] Dependency add/remove receipts report the resulting derived state of each directly affected dependent and human output calls out a newly blocked, broken, or inconsistent task. - [ ] Eligibility authorization and the persisted lifecycle transition occur under one repository guard over the same authoritative graph snapshot. - [ ] Lifecycle writes use a dedicated use-case-specific guarded capability diff --git a/planning/tasks/6g3q4rtmv4ak-add-the-thread-entity-lifecycle-and-graph-projections.md b/planning/tasks/6g3q4rtmv4ak-add-the-thread-entity-lifecycle-and-graph-projections.md index f9386952..fa8cc839 100644 --- a/planning/tasks/6g3q4rtmv4ak-add-the-thread-entity-lifecycle-and-graph-projections.md +++ b/planning/tasks/6g3q4rtmv4ak-add-the-thread-entity-lifecycle-and-graph-projections.md @@ -11,6 +11,7 @@ autonomy_level: 3 tags: [threads, domain, storage, cli] created: "2026-08-25" updated_at: "2026-08-27" +depends_on: [6g3q4rte8kc1] --- # Add the Thread entity, lifecycle, and graph projections diff --git a/planning/tasks/6g3q4rtv8d0a-bulk-link-existing-tasks-into-threads-with-resumable-apply.md b/planning/tasks/6g3q4rtv8d0a-bulk-link-existing-tasks-into-threads-with-resumable-apply.md index ccd089e4..503c5b17 100644 --- a/planning/tasks/6g3q4rtv8d0a-bulk-link-existing-tasks-into-threads-with-resumable-apply.md +++ b/planning/tasks/6g3q4rtv8d0a-bulk-link-existing-tasks-into-threads-with-resumable-apply.md @@ -11,6 +11,7 @@ autonomy_level: 3 tags: [threads, graph, cli, workflow] created: "2026-08-25" updated_at: "2026-08-27" +depends_on: [6g3q4rtmv4ak] --- # Bulk-link existing tasks into Threads with resumable apply diff --git a/planning/tasks/6g3q4rv1w9e2-generate-deterministic-thread-graph-views.md b/planning/tasks/6g3q4rv1w9e2-generate-deterministic-thread-graph-views.md index 28609ab2..592c6f14 100644 --- a/planning/tasks/6g3q4rv1w9e2-generate-deterministic-thread-graph-views.md +++ b/planning/tasks/6g3q4rv1w9e2-generate-deterministic-thread-graph-views.md @@ -10,6 +10,8 @@ priority: medium autonomy_level: 4 tags: [threads, graph, cli, rendering] created: "2026-08-25" +depends_on: [6g3q4rtv8d0a] +updated_at: "2026-08-27" --- # Generate deterministic Thread graph views diff --git a/planning/tasks/6g3q4rv89vzw-add-usage-informed-thread-views-to-the-tui.md b/planning/tasks/6g3q4rv89vzw-add-usage-informed-thread-views-to-the-tui.md index 3f9a443f..f7f8b9a2 100644 --- a/planning/tasks/6g3q4rv89vzw-add-usage-informed-thread-views-to-the-tui.md +++ b/planning/tasks/6g3q4rv89vzw-add-usage-informed-thread-views-to-the-tui.md @@ -11,6 +11,7 @@ autonomy_level: 3 tags: [threads, tui, graph, ux] created: "2026-08-25" updated_at: "2026-08-27" +depends_on: [6g3q4rv1w9e2] --- # Add usage-informed Thread views to the TUI diff --git a/planning/tasks/6g4g8gatbnrs-add-a-guarded-repair-path-for-broken-dependency-graphs.md b/planning/tasks/6g4g8gatbnrs-add-a-guarded-repair-path-for-broken-dependency-graphs.md new file mode 100644 index 00000000..dce0bdfe --- /dev/null +++ b/planning/tasks/6g4g8gatbnrs-add-a-guarded-repair-path-for-broken-dependency-graphs.md @@ -0,0 +1,49 @@ +--- +schema: 1 +id: 6g4g8gatbnrs +status: next-up +epic: 30-threads-and-task-dependency-graphs +description: Repair cycles, dangling edges, and other broken graph-owned state without requiring an unsafe generic mutation path. +effort: 3-5 days +tier: 2 +priority: high +autonomy_level: 3 +tags: [threads, graph, storage, cli] +created: "2026-08-28" +depends_on: [6g3q4rt7mgjn] +updated_at: "2026-08-28" +--- + +# Add a guarded repair path for broken dependency graphs + +## Objective + +Provide an explicit recovery capability for graph-owned frontmatter that is already broken, without making generic setters or ordinary dependency mutations capable of bypassing repository-global validation. + +## Scope + +- Repair canonical cycles, self-edges, duplicate edges, and missing/invalid `depends_on` references through one guarded, narrowly typed operation. +- Admit only plans that strictly reduce a deterministic graph-problem measure at every durable prefix and never introduce a new problem class or affected task. +- Preserve the existing repository lock, snapshot-local planning, whole-snapshot/per-file CAS, surgical frontmatter updates, dry-run, and typed recovery receipts. +- Provide exact file/field/problem diagnoses and an idempotent retry path when a multi-file repair stops after a durable prefix. +- Decide whether the user surface belongs under `task depend repair`, `lint --fix --graph`, or a smaller family of explicit removal verbs before freezing CLI syntax. + +## Acceptance criteria + +- [ ] A broken source graph can enter only the dedicated repair planner; ordinary add/remove/migrate and generic task mutation continue to fail closed. +- [ ] The repair proof uses a documented deterministic measure and rejects any plan whose prefix fails to improve or preserve safety while moving monotonically toward a healthy graph. +- [ ] Cycle, self-edge, dangling-reference, invalid-ID, and duplicate-edge fixtures each have an actionable preview and converge to the intended healthy state. +- [ ] A repair cannot silently discard an unrelated valid constraint, widen the affected component, or reinterpret legacy slug references. +- [ ] Concurrent task/dependency edits produce a typed conflict rather than a stale repair, and every injected durable prefix is diagnosable and resumable. +- [ ] Human and JSON output name every removed/replaced declaration, the original problems addressed, remaining problems, workspace, and applied/remaining files. +- [ ] Normal lint points to the repair command once it exists; until then it remains explicit that direct frontmatter editing is the only recovery path. + +## Out of scope + +- Automatic best-guess repair of ambiguous legacy references. +- A generic `--force` escape hatch for arbitrary graph writes. +- Rewriting lifecycle status or Thread membership as part of dependency repair. + +## Related + +- Epic [30-threads-and-task-dependency-graphs](../epics/30-threads-and-task-dependency-graphs.md)