From 6f417587c4950ed776bb545266c3ade3e9e73ff8 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 00:43:03 +0530 Subject: [PATCH 01/11] =?UTF-8?q?TP-012:=20docops=20get/list/graph/next=20?= =?UTF-8?q?=E2=80=94=20focused=20read=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the four CLI query commands defined in ADR-0018. Agents can now retrieve a focused slice of the index without loading the full .index.json: look up one doc (get), filter/sort the listing (list), walk typed edges (graph), or get a prioritised task recommendation (next). Shared bootstrap extracted to bootstrap.go. 27 new tests. Co-Authored-By: Claude Sonnet 4.6 --- cmd/docops/bootstrap.go | 68 ++ cmd/docops/cmd_get.go | 145 ++++ cmd/docops/cmd_graph.go | 262 +++++++ cmd/docops/cmd_list.go | 206 ++++++ cmd/docops/cmd_next.go | 161 +++++ cmd/docops/cmd_read_test.go | 687 +++++++++++++++++++ cmd/docops/main.go | 15 +- docs/.index.json | 16 +- docs/STATE.md | 4 +- docs/tasks/TP-012-implement-read-commands.md | 4 +- 10 files changed, 1553 insertions(+), 15 deletions(-) create mode 100644 cmd/docops/bootstrap.go create mode 100644 cmd/docops/cmd_get.go create mode 100644 cmd/docops/cmd_graph.go create mode 100644 cmd/docops/cmd_list.go create mode 100644 cmd/docops/cmd_next.go create mode 100644 cmd/docops/cmd_read_test.go diff --git a/cmd/docops/bootstrap.go b/cmd/docops/bootstrap.go new file mode 100644 index 0000000..97fc6d8 --- /dev/null +++ b/cmd/docops/bootstrap.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/index" + "github.com/logicwind/docops/internal/loader" + "github.com/logicwind/docops/internal/validator" +) + +// bootstrapIndex is the shared bootstrap sequence for read-only commands: +// find config, load docs, validate, build in-memory index. +// On any failure it prints a prefixed error to stderr and returns code 2. +func bootstrapIndex(cmd string) (*index.Index, int) { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, 2 + } + cfg, root, err := config.FindAndLoad(cwd) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "docops %s: no docops.yaml found — run `docops init` first\n", cmd) + return nil, 2 + } + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, 2 + } + set, err := loader.Load(root, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, 2 + } + report := validator.Validate(set, cfg) + if !report.OK() { + fmt.Fprintf(os.Stderr, "docops %s: refusing: %d validation error(s); run 'docops validate'\n", cmd, len(report.Errors)) + return nil, 2 + } + idx, err := index.Build(set, cfg, root, time.Now()) + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: build index: %v\n", cmd, err) + return nil, 2 + } + return idx, 0 +} + +// indexLookup finds a doc by ID. +func indexLookup(idx *index.Index, id string) (index.IndexedDoc, bool) { + for _, doc := range idx.Docs { + if doc.ID == id { + return doc, true + } + } + return index.IndexedDoc{}, false +} + +// indexByID builds a map from ID → IndexedDoc for O(1) lookup. +func indexByID(idx *index.Index) map[string]index.IndexedDoc { + m := make(map[string]index.IndexedDoc, len(idx.Docs)) + for _, doc := range idx.Docs { + m[doc.ID] = doc + } + return m +} diff --git a/cmd/docops/cmd_get.go b/cmd/docops/cmd_get.go new file mode 100644 index 0000000..1b734ab --- /dev/null +++ b/cmd/docops/cmd_get.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +// cmdGet implements `docops get [--json]`. +// Exit codes: +// +// 0 found +// 1 not found +// 2 bootstrap error or bad usage +func cmdGet(args []string) int { + fs := flag.NewFlagSet("get", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit the full IndexedDoc as JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops get [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(os.Stderr, "docops get: requires exactly one argument ") + fs.Usage() + return 2 + } + id := fs.Arg(0) + + idx, code := bootstrapIndex("get") + if code != 0 { + return code + } + + doc, ok := indexLookup(idx, id) + if !ok { + fmt.Fprintf(os.Stderr, "docops get: %q not found\n", id) + return 1 + } + + if *asJSON { + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops get: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + fmt.Print(humanGet(doc)) + return 0 +} + +func humanGet(doc index.IndexedDoc) string { + var sb strings.Builder + fmt.Fprintf(&sb, "%s %s\n", doc.ID, doc.CTXTitle) + + field := func(k, v string) { + if v != "" { + fmt.Fprintf(&sb, " %-16s %s\n", k+":", v) + } + } + + field("kind", doc.Kind) + switch doc.Kind { + case "ADR": + field("status", doc.ADRStatus) + field("coverage", doc.ADRCoverage) + field("date", doc.ADRDate) + if len(doc.ADRTags) > 0 { + field("tags", strings.Join(doc.ADRTags, ", ")) + } + if len(doc.CTXSupersedes) > 0 { + field("supersedes", strings.Join(doc.CTXSupersedes, ", ")) + } + if len(doc.ADRRelated) > 0 { + field("related", strings.Join(doc.ADRRelated, ", ")) + } + field("implementation", doc.Implementation) + case "TP": + field("status", doc.TaskStatus) + field("priority", doc.TaskPriority) + field("assignee", doc.TaskAssignee) + if len(doc.TaskRequires) > 0 { + field("requires", strings.Join(doc.TaskRequires, ", ")) + } + if len(doc.TaskDependsOn) > 0 { + field("depends_on", strings.Join(doc.TaskDependsOn, ", ")) + } + case "CTX": + field("type", doc.CTXType) + if len(doc.CTXSupersedes) > 0 { + field("supersedes", strings.Join(doc.CTXSupersedes, ", ")) + } + } + + field("summary", doc.Summary) + lt := doc.LastTouched + if len(lt) >= 10 { + lt = lt[:10] + } + field("last_touched", lt) + fmt.Fprintf(&sb, " %-16s %d\n", "age_days:", doc.AgeDays) + fmt.Fprintf(&sb, " %-16s %d\n", "word_count:", doc.WordCount) + staleStr := "false" + if doc.Stale { + staleStr = "true" + } + field("stale", staleStr) + + // Reverse edges. + if len(doc.SupersededBy) > 0 { + field("superseded_by", strings.Join(doc.SupersededBy, ", ")) + } + if len(doc.ReferencedBy) > 0 { + parts := make([]string, len(doc.ReferencedBy)) + for i, r := range doc.ReferencedBy { + parts[i] = fmt.Sprintf("%s (%s)", r.ID, r.Edge) + } + field("referenced_by", strings.Join(parts, ", ")) + } + if len(doc.ActiveTasks) > 0 { + field("active_tasks", strings.Join(doc.ActiveTasks, ", ")) + } + if len(doc.DerivedADRs) > 0 { + field("derived_adrs", strings.Join(doc.DerivedADRs, ", ")) + } + if len(doc.Blocks) > 0 { + field("blocks", strings.Join(doc.Blocks, ", ")) + } + + return sb.String() +} diff --git a/cmd/docops/cmd_graph.go b/cmd/docops/cmd_graph.go new file mode 100644 index 0000000..9f4a991 --- /dev/null +++ b/cmd/docops/cmd_graph.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +type graphEdge struct { + From string `json:"from"` + To string `json:"to"` + Edge string `json:"edge"` + Direction string `json:"direction"` // "outbound" or "inbound" +} + +type graphOutput struct { + Root string `json:"root"` + Nodes []index.IndexedDoc `json:"nodes"` + Edges []graphEdge `json:"edges"` +} + +// cmdGraph implements `docops graph [--depth N] [--json]`. +// Exit codes: +// +// 0 success +// 1 ID not found +// 2 bootstrap error or bad usage +func cmdGraph(args []string) int { + fs := flag.NewFlagSet("graph", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit graph as JSON") + depth := fs.Int("depth", 1, "traversal depth") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops graph [--depth N] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(os.Stderr, "docops graph: requires exactly one argument ") + fs.Usage() + return 2 + } + id := fs.Arg(0) + if *depth < 0 { + *depth = 0 + } + + idx, code := bootstrapIndex("graph") + if code != 0 { + return code + } + + byID := indexByID(idx) + if _, ok := byID[id]; !ok { + fmt.Fprintf(os.Stderr, "docops graph: %q not found\n", id) + return 1 + } + + nodes, edges := walkGraph(id, byID, *depth) + + if *asJSON { + out := graphOutput{Root: id, Nodes: nodes, Edges: edges} + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops graph: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + fmt.Print(humanGraph(id, byID, edges, *depth)) + return 0 +} + +// walkGraph performs a BFS from startID up to maxDepth hops, collecting +// visited nodes and the edges between them. Cycle-safe via visited set. +func walkGraph(startID string, byID map[string]index.IndexedDoc, maxDepth int) ([]index.IndexedDoc, []graphEdge) { + type qItem struct { + id string + depth int + } + + visited := map[string]struct{}{} + edgeSeen := map[string]struct{}{} + var nodes []index.IndexedDoc + var edges []graphEdge + queue := []qItem{{startID, 0}} + + addEdge := func(from, to, label, dir string) { + key := from + "|" + to + "|" + label + "|" + dir + if _, seen := edgeSeen[key]; seen { + return + } + edgeSeen[key] = struct{}{} + edges = append(edges, graphEdge{From: from, To: to, Edge: label, Direction: dir}) + } + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if _, seen := visited[item.id]; seen { + continue + } + visited[item.id] = struct{}{} + + doc, ok := byID[item.id] + if !ok { + continue + } + nodes = append(nodes, doc) + + if item.depth >= maxDepth { + continue + } + next := item.depth + 1 + + // Outbound edges. + for _, t := range doc.CTXSupersedes { + addEdge(item.id, t, "supersedes", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.ADRRelated { + addEdge(item.id, t, "related", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.TaskRequires { + addEdge(item.id, t, "requires", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.TaskDependsOn { + addEdge(item.id, t, "depends_on", "outbound") + queue = append(queue, qItem{t, next}) + } + + // Inbound (reverse) edges. + for _, t := range doc.SupersededBy { + addEdge(t, item.id, "supersedes", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, ref := range doc.ReferencedBy { + addEdge(ref.ID, item.id, ref.Edge, "inbound") + queue = append(queue, qItem{ref.ID, next}) + } + for _, t := range doc.ActiveTasks { + addEdge(t, item.id, "active_tasks", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.DerivedADRs { + addEdge(t, item.id, "derived_adrs", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.Blocks { + addEdge(t, item.id, "blocks", "inbound") + queue = append(queue, qItem{t, next}) + } + } + + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + sort.Slice(edges, func(i, j int) bool { + if edges[i].From != edges[j].From { + return edges[i].From < edges[j].From + } + if edges[i].To != edges[j].To { + return edges[i].To < edges[j].To + } + return edges[i].Edge < edges[j].Edge + }) + + return nodes, edges +} + +// humanGraph renders a depth-first indented tree rooted at rootID. +// Already-visited nodes are printed inline with "(visited)" rather than +// being expanded again. +func humanGraph(rootID string, byID map[string]index.IndexedDoc, edges []graphEdge, maxDepth int) string { + // Build per-node adjacency (both outbound and inbound from that node's POV). + type nodeEdge struct { + arrow string // "→" or "←" + label string + peerID string + } + adj := map[string][]nodeEdge{} + for _, e := range edges { + if e.Direction == "outbound" { + adj[e.From] = append(adj[e.From], nodeEdge{"→", e.Edge, e.To}) + } else { + adj[e.To] = append(adj[e.To], nodeEdge{"←", e.Edge, e.From}) + } + } + // Sort each adjacency list for determinism. + for id := range adj { + sort.Slice(adj[id], func(i, j int) bool { + a, b := adj[id][i], adj[id][j] + if a.arrow != b.arrow { + return a.arrow < b.arrow + } + if a.label != b.label { + return a.label < b.label + } + return a.peerID < b.peerID + }) + } + + var sb strings.Builder + visited := map[string]bool{} + + var render func(id string, depth int) + render = func(id string, depth int) { + indent := strings.Repeat(" ", depth) + doc, ok := byID[id] + if !ok { + return + } + if depth == 0 { + fmt.Fprintf(&sb, "%s%s — %s [%s%s]\n", indent, doc.ID, doc.CTXTitle, doc.Kind, docKindStatus(doc)) + } + visited[id] = true + if depth >= maxDepth { + return + } + for _, ne := range adj[id] { + peer, ok := byID[ne.peerID] + if !ok { + continue + } + extra := "" + if visited[ne.peerID] { + extra = " (visited)" + } + fmt.Fprintf(&sb, "%s %s %-14s %s — %s [%s%s]%s\n", + indent, ne.arrow, ne.label, peer.ID, peer.CTXTitle, peer.Kind, docKindStatus(peer), extra) + if !visited[ne.peerID] { + render(ne.peerID, depth+1) + } + } + } + + render(rootID, 0) + return sb.String() +} + +// docKindStatus returns ", " for kinds that carry a status, else "". +func docKindStatus(doc index.IndexedDoc) string { + s := docStatusField(doc) + if s == "" { + return "" + } + return ", " + s +} diff --git a/cmd/docops/cmd_list.go b/cmd/docops/cmd_list.go new file mode 100644 index 0000000..b6eb4ff --- /dev/null +++ b/cmd/docops/cmd_list.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/logicwind/docops/internal/index" +) + +// listRecord is the trimmed per-doc view emitted by `docops list`. +type listRecord struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status,omitempty"` + Title string `json:"title"` + Coverage string `json:"coverage,omitempty"` + Priority string `json:"priority,omitempty"` + Assignee string `json:"assignee,omitempty"` + LastTouched string `json:"last_touched"` + Stale bool `json:"stale"` + Implementation string `json:"implementation,omitempty"` +} + +// cmdList implements `docops list [flags] [--json]`. +// Exit codes: +// +// 0 success (even if no docs match) +// 2 bootstrap error or bad usage +func cmdList(args []string) int { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit records as JSON array") + kindFlag := fs.String("kind", "", "filter by kind: CTX, ADR, TP") + statusFlag := fs.String("status", "", "filter by status (per-kind semantics)") + coverage := fs.String("coverage", "", "filter ADRs by coverage: required, not-needed") + tag := fs.String("tag", "", "filter ADRs by tag") + onlyStale := fs.Bool("stale", false, "only docs with stale=true") + since := fs.String("since", "", "only docs with last_touched >= YYYY-MM-DD") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops list [flags]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + idx, code := bootstrapIndex("list") + if code != 0 { + return code + } + + var sinceTime time.Time + if *since != "" { + t, err := time.Parse("2006-01-02", *since) + if err != nil { + fmt.Fprintf(os.Stderr, "docops list: --since %q: expected YYYY-MM-DD\n", *since) + return 2 + } + sinceTime = t + } + + kindUpper := strings.ToUpper(*kindFlag) + var records []listRecord + for _, doc := range idx.Docs { + if kindUpper != "" && doc.Kind != kindUpper { + continue + } + docStatus := docStatusField(doc) + if *statusFlag != "" && docStatus != *statusFlag { + continue + } + if *coverage != "" && doc.ADRCoverage != *coverage { + continue + } + if *tag != "" && !sliceContains(doc.ADRTags, *tag) { + continue + } + if *onlyStale && !doc.Stale { + continue + } + if !sinceTime.IsZero() { + lt, err := time.Parse(time.RFC3339, doc.LastTouched) + if err == nil && lt.Before(sinceTime) { + continue + } + } + records = append(records, listRecord{ + ID: doc.ID, + Kind: doc.Kind, + Status: docStatus, + Title: doc.CTXTitle, + Coverage: doc.ADRCoverage, + Priority: doc.TaskPriority, + Assignee: doc.TaskAssignee, + LastTouched: doc.LastTouched, + Stale: doc.Stale, + Implementation: doc.Implementation, + }) + } + + // Sort: kind order (CTX→ADR→TP) then ID ascending. + sort.SliceStable(records, func(i, j int) bool { + ki := kindOrder(records[i].Kind) + kj := kindOrder(records[j].Kind) + if ki != kj { + return ki < kj + } + return records[i].ID < records[j].ID + }) + + if *asJSON { + if records == nil { + records = []listRecord{} + } + b, err := json.MarshalIndent(records, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops list: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + if len(records) == 0 { + fmt.Println("(no documents match)") + return 0 + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tKIND\tSTATUS\tTITLE\tCOV/PRI\tIMPL/ASSIGNEE\tLAST-TOUCHED\tSTALE") + for _, r := range records { + extra1 := r.Coverage + if extra1 == "" { + extra1 = r.Priority + } + extra2 := r.Implementation + if extra2 == "" { + extra2 = r.Assignee + } + lt := r.LastTouched + if len(lt) >= 10 { + lt = lt[:10] + } + staleStr := "no" + if r.Stale { + staleStr = "yes" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.ID, r.Kind, r.Status, truncate(r.Title, 40), + extra1, extra2, lt, staleStr) + } + _ = tw.Flush() + return 0 +} + +// docStatusField returns the kind-appropriate status string (empty for CTX). +func docStatusField(doc index.IndexedDoc) string { + switch doc.Kind { + case "ADR": + return doc.ADRStatus + case "TP": + return doc.TaskStatus + } + return "" +} + +func kindOrder(k string) int { + switch k { + case "CTX": + return 0 + case "ADR": + return 1 + case "TP": + return 2 + } + return 3 +} + +// sliceContains reports whether s appears in sl. +func sliceContains(sl []string, s string) bool { + for _, v := range sl { + if v == s { + return true + } + } + return false +} + +// truncate clips s to maxLen runes, appending "…" if truncated. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "…" +} diff --git a/cmd/docops/cmd_next.go b/cmd/docops/cmd_next.go new file mode 100644 index 0000000..f6af8e4 --- /dev/null +++ b/cmd/docops/cmd_next.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +type nextOutput struct { + index.IndexedDoc + Reason string `json:"reason"` +} + +// cmdNext implements `docops next [--assignee ] [--priority p0|p1|p2] [--json]`. +// Selection rules (first match wins): +// 1. Active tasks matching filters, sorted by last_touched descending. +// 2. Unblocked backlog tasks (all depends_on targets are done), sorted by +// priority (p0>p1>p2) then ID ascending. +// +// Exit codes: +// +// 0 task found +// 1 no task matches +// 2 bootstrap error or bad usage +func cmdNext(args []string) int { + fs := flag.NewFlagSet("next", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit the selected task as JSON") + assignee := fs.String("assignee", "", "filter by assignee") + priority := fs.String("priority", "", "filter by priority: p0, p1, p2") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops next [--assignee ] [--priority ] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + idx, code := bootstrapIndex("next") + if code != 0 { + return code + } + + doneIDs := map[string]bool{} + for _, doc := range idx.Docs { + if doc.Kind == "TP" && doc.TaskStatus == "done" { + doneIDs[doc.ID] = true + } + } + + matchesFilters := func(doc index.IndexedDoc) bool { + if *assignee != "" && doc.TaskAssignee != *assignee { + return false + } + if *priority != "" && doc.TaskPriority != *priority { + return false + } + return true + } + + // Rule 1: active tasks. + var active []index.IndexedDoc + for _, doc := range idx.Docs { + if doc.Kind == "TP" && doc.TaskStatus == "active" && matchesFilters(doc) { + active = append(active, doc) + } + } + sort.Slice(active, func(i, j int) bool { + // Most recently touched first. + return active[i].LastTouched > active[j].LastTouched + }) + if len(active) > 0 { + t := active[0] + assigneeName := t.TaskAssignee + if assigneeName == "" { + assigneeName = "unassigned" + } + return emitNext(t, "active for "+assigneeName, *asJSON) + } + + // Rule 2+3: unblocked backlog tasks. + var unblocked []index.IndexedDoc + for _, doc := range idx.Docs { + if doc.Kind != "TP" || doc.TaskStatus != "backlog" { + continue + } + if !matchesFilters(doc) { + continue + } + allDone := true + for _, dep := range doc.TaskDependsOn { + if !doneIDs[dep] { + allDone = false + break + } + } + if allDone { + unblocked = append(unblocked, doc) + } + } + + // Rule 4: priority (p0>p1>p2) then ID ascending. + sort.Slice(unblocked, func(i, j int) bool { + pi := priorityOrder(unblocked[i].TaskPriority) + pj := priorityOrder(unblocked[j].TaskPriority) + if pi != pj { + return pi < pj + } + return unblocked[i].ID < unblocked[j].ID + }) + if len(unblocked) > 0 { + t := unblocked[0] + reason := "unblocked backlog, " + t.TaskPriority + return emitNext(t, reason, *asJSON) + } + + fmt.Fprintln(os.Stderr, "docops next: no task matches") + return 1 +} + +func emitNext(task index.IndexedDoc, reason string, asJSON bool) int { + if asJSON { + out := nextOutput{IndexedDoc: task, Reason: reason} + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops next: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + reqs := strings.Join(task.TaskRequires, ", ") + if reqs == "" { + reqs = "(none)" + } + fmt.Printf("%s (%s, %s) %s — requires: %s\n", + task.ID, task.TaskAssignee, task.TaskPriority, task.CTXTitle, reqs) + fmt.Printf("reason: %s\n", reason) + return 0 +} + +func priorityOrder(p string) int { + switch p { + case "p0": + return 0 + case "p1": + return 1 + case "p2": + return 2 + } + return 99 +} diff --git a/cmd/docops/cmd_read_test.go b/cmd/docops/cmd_read_test.go new file mode 100644 index 0000000..673614d --- /dev/null +++ b/cmd/docops/cmd_read_test.go @@ -0,0 +1,687 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// captureStdout captures os.Stdout output from fn, restoring it afterward. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = orig + + buf := make([]byte, 1<<20) + n, _ := r.Read(buf) + return string(buf[:n]) +} + +// readTestDocs are planting helpers reused across the read-command tests. + +const validCTX = `--- +title: CLI substrate +type: prd +supersedes: [] +--- + +# CLI substrate + +Body of the CLI substrate context doc. +` + +const validADR2 = `--- +title: Use Go for implementation +status: accepted +coverage: required +date: 2026-01-01 +supersedes: [] +related: [] +tags: [cli, go] +--- + +# Use Go for implementation + +## Context + +We need a language. + +## Decision + +Go. +` + +const taskDone = `--- +title: Scaffold the CLI +status: done +priority: p1 +assignee: alice +requires: [ADR-0001] +depends_on: [] +--- + +# Scaffold the CLI + +## Goal + +## Acceptance + +## Notes +` + +const taskBacklog1 = `--- +title: Implement validate +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [TP-001] +--- + +# Implement validate + +## Goal + +## Acceptance + +## Notes +` + +const taskBacklog2 = `--- +title: Ship release +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# Ship release + +## Goal + +## Acceptance + +## Notes +` + +const taskActive = `--- +title: Write docs +status: active +priority: p0 +assignee: bob +requires: [ADR-0001] +depends_on: [] +--- + +# Write docs + +## Goal + +## Acceptance + +## Notes +` + +// plantReadDocs creates a full test tree and returns the root dir. +// Layout: +// +// CTX-001 CLI substrate +// ADR-0001 Use Go for implementation (tags: [cli, go]) +// TP-001 Scaffold the CLI done, p1, alice, requires ADR-0001 +// TP-002 Implement validate backlog, p1, depends on TP-001 (blocked until TP-001 done) +// TP-003 Ship release backlog, p2, no deps (unblocked) +// TP-004 Write docs active, p0, bob, requires ADR-0001 +func plantReadDocs(t *testing.T) string { + t.Helper() + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/context/CTX-001-cli-substrate.md", validCTX) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) + plantDoc(t, root, "docs/tasks/TP-004-docs.md", taskActive) + return root +} + +// ── docops get ────────────────────────────────────────────────────────────── + +func TestCmdGet_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGet([]string{"ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGet returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("output missing ID: %s", out) + } + if !strings.Contains(out, "Use Go for implementation") { + t.Errorf("output missing title: %s", out) + } + if !strings.Contains(out, "accepted") { + t.Errorf("output missing status: %s", out) + } +} + +func TestCmdGet_NotFound(t *testing.T) { + plantReadDocs(t) + code := cmdGet([]string{"ADR-9999"}) + if code != 1 { + t.Errorf("cmdGet unknown ID returned %d, want 1", code) + } +} + +func TestCmdGet_MissingArg(t *testing.T) { + plantReadDocs(t) + code := cmdGet(nil) + if code != 2 { + t.Errorf("cmdGet no args returned %d, want 2", code) + } +} + +func TestCmdGet_NoConfig(t *testing.T) { + root := t.TempDir() + orig, _ := os.Getwd() + _ = os.Chdir(root) + t.Cleanup(func() { _ = os.Chdir(orig) }) + + code := cmdGet([]string{"ADR-0001"}) + if code != 2 { + t.Errorf("cmdGet no config returned %d, want 2", code) + } +} + +func TestCmdGet_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGet([]string{"--json", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGet --json returned %d", code) + } + }) + + var doc map[string]interface{} + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("--json output is not valid JSON: %v\n%s", err, out) + } + if doc["id"] != "ADR-0001" { + t.Errorf("JSON id = %v, want ADR-0001", doc["id"]) + } + if doc["kind"] != "ADR" { + t.Errorf("JSON kind = %v, want ADR", doc["kind"]) + } +} + +// ── docops list ───────────────────────────────────────────────────────────── + +func TestCmdList_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdList(nil) + if code != 0 { + t.Fatalf("cmdList returned %d", code) + } + }) + + // Should contain all six docs. + for _, id := range []string{"CTX-001", "ADR-0001", "TP-001", "TP-002", "TP-003", "TP-004"} { + if !strings.Contains(out, id) { + t.Errorf("output missing %s:\n%s", id, out) + } + } +} + +func TestCmdList_KindFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--kind", "ADR"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 missing from ADR filter: %s", out) + } + if strings.Contains(out, "TP-001") { + t.Errorf("TP-001 should not appear in ADR filter: %s", out) + } + if strings.Contains(out, "CTX-001") { + t.Errorf("CTX-001 should not appear in ADR filter: %s", out) + } +} + +func TestCmdList_StatusFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--status", "done"}) + }) + + if !strings.Contains(out, "TP-001") { + t.Errorf("TP-001 (done) missing: %s", out) + } + if strings.Contains(out, "TP-002") { + t.Errorf("TP-002 (backlog) should not appear: %s", out) + } +} + +func TestCmdList_TagFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--tag", "go"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (tagged go) missing: %s", out) + } +} + +func TestCmdList_TagFilterNoMatch(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--tag", "nonexistent"}) + }) + + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 should not match tag=nonexistent: %s", out) + } + if !strings.Contains(out, "(no documents match)") { + t.Errorf("expected '(no documents match)': %s", out) + } +} + +func TestCmdList_KindSortOrder(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList(nil) + }) + + // CTX must appear before ADR, ADR before TP in human output. + ctxPos := strings.Index(out, "CTX-001") + adrPos := strings.Index(out, "ADR-0001") + tpPos := strings.Index(out, "TP-001") + if ctxPos < 0 || adrPos < 0 || tpPos < 0 { + t.Fatalf("missing expected IDs in output: %s", out) + } + if ctxPos >= adrPos { + t.Errorf("CTX should appear before ADR (ctxPos=%d adrPos=%d)", ctxPos, adrPos) + } + if adrPos >= tpPos { + t.Errorf("ADR should appear before TP (adrPos=%d tpPos=%d)", adrPos, tpPos) + } +} + +func TestCmdList_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdList([]string{"--json"}) + if code != 0 { + t.Fatalf("cmdList --json returned %d", code) + } + }) + + var records []map[string]interface{} + if err := json.Unmarshal([]byte(out), &records); err != nil { + t.Fatalf("--json output is not valid JSON: %v\n%s", err, out) + } + if len(records) != 6 { + t.Errorf("expected 6 records, got %d", len(records)) + } +} + +func TestCmdList_JSONEmptyOnNoMatch(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--json", "--kind", "ADR", "--status", "draft"}) + }) + + var records []map[string]interface{} + if err := json.Unmarshal([]byte(out), &records); err != nil { + t.Fatalf("--json output not valid JSON: %v\n%s", err, out) + } + if len(records) != 0 { + t.Errorf("expected empty array, got %d records", len(records)) + } +} + +func TestCmdList_CoverageFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--coverage", "required"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (coverage=required) missing: %s", out) + } +} + +// ── docops graph ───────────────────────────────────────────────────────────── + +func TestCmdGraph_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph returned %d", code) + } + }) + + // Root must be the first line. + if !strings.HasPrefix(out, "ADR-0001") { + t.Errorf("first line should start with ADR-0001:\n%s", out) + } + // Depth-1 neighbours: tasks that require ADR-0001 should appear. + if !strings.Contains(out, "TP-001") && !strings.Contains(out, "TP-004") { + t.Errorf("expected referencing tasks in output:\n%s", out) + } +} + +func TestCmdGraph_NotFound(t *testing.T) { + plantReadDocs(t) + code := cmdGraph([]string{"ADR-9999"}) + if code != 1 { + t.Errorf("cmdGraph unknown ID returned %d, want 1", code) + } +} + +func TestCmdGraph_MissingArg(t *testing.T) { + plantReadDocs(t) + code := cmdGraph(nil) + if code != 2 { + t.Errorf("cmdGraph no args returned %d, want 2", code) + } +} + +func TestCmdGraph_DepthZero(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--depth", "0", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph --depth 0 returned %d", code) + } + }) + + // With depth 0 we get only the root node, no edge lines. + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 1 { + t.Errorf("depth 0 should produce 1 line, got %d:\n%s", len(lines), out) + } +} + +func TestCmdGraph_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--json", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph --json returned %d", code) + } + }) + + var g struct { + Root string `json:"root"` + Nodes []map[string]interface{} `json:"nodes"` + Edges []map[string]interface{} `json:"edges"` + } + if err := json.Unmarshal([]byte(out), &g); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if g.Root != "ADR-0001" { + t.Errorf("root = %q, want ADR-0001", g.Root) + } + if len(g.Nodes) == 0 { + t.Error("expected at least 1 node") + } +} + +func TestCmdGraph_CycleSafe(t *testing.T) { + // Even with mutual edges the walker must not loop. + // TP-002 depends_on TP-001; graph from TP-001 at depth 2 should terminate. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--depth", "2", "TP-001"}) + if code != 0 { + t.Fatalf("cmdGraph depth 2 returned %d", code) + } + }) + + // Just verify we got output without hanging; TP-001 must appear. + if !strings.Contains(out, "TP-001") { + t.Errorf("TP-001 missing from output:\n%s", out) + } +} + +// ── docops next ────────────────────────────────────────────────────────────── + +func TestCmdNext_ActiveFirst(t *testing.T) { + // TP-004 is active (bob, p0); it should win over any backlog. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "TP-004") { + t.Errorf("expected active TP-004, got:\n%s", out) + } + if !strings.Contains(out, "active for bob") { + t.Errorf("expected 'active for bob' reason:\n%s", out) + } +} + +func TestCmdNext_UnblockedBacklog(t *testing.T) { + // With no active tasks, TP-003 (backlog, p2, no deps) and TP-002 (backlog, p1, + // depends_on TP-001 which is done) are both unblocked. TP-002 (p1) wins. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) // p1, depends_on TP-001 (done) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) // p2, no deps + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "TP-002") { + t.Errorf("expected TP-002 (p1 unblocked), got:\n%s", out) + } + if !strings.Contains(out, "unblocked backlog") { + t.Errorf("expected 'unblocked backlog' reason:\n%s", out) + } +} + +func TestCmdNext_BlockedTaskSkipped(t *testing.T) { + // TP-002 depends_on TP-001 which is NOT done (backlog). Only TP-003 is unblocked. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + + const tp001Backlog = `--- +title: Scaffold the CLI +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# Scaffold the CLI + +## Goal + +## Acceptance + +## Notes +` + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", tp001Backlog) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) // depends_on TP-001 (not done) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) // p2, no deps + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d", code) + } + }) + + // TP-002 is blocked (TP-001 not done). Should pick TP-001 (p1) or TP-003 (p2). + // Both TP-001 and TP-003 are unblocked; TP-001 is p1 so it wins. + if strings.Contains(out, "TP-002") { + t.Errorf("blocked TP-002 should not be selected:\n%s", out) + } +} + +func TestCmdNext_PriorityOrder(t *testing.T) { + // Three unblocked backlog tasks at p0, p1, p2. p0 must win. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + + tasks := []struct { + file string + content string + }{ + {"docs/tasks/TP-001-p0.md", `--- +title: P0 task +status: backlog +priority: p0 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P0 task + +## Goal + +## Acceptance + +## Notes +`}, + {"docs/tasks/TP-002-p1.md", `--- +title: P1 task +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P1 task + +## Goal + +## Acceptance + +## Notes +`}, + {"docs/tasks/TP-003-p2.md", `--- +title: P2 task +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P2 task + +## Goal + +## Acceptance + +## Notes +`}, + } + for _, tc := range tasks { + plantDoc(t, root, tc.file, tc.content) + } + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d", code) + } + }) + + if !strings.Contains(out, "TP-001") { + t.Errorf("p0 task TP-001 should win, got:\n%s", out) + } +} + +func TestCmdNext_AssigneeFilter(t *testing.T) { + // --assignee bob: should find TP-004 (active, bob). + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext([]string{"--assignee", "bob"}) + if code != 0 { + t.Fatalf("cmdNext --assignee bob returned %d", code) + } + }) + + if !strings.Contains(out, "TP-004") { + t.Errorf("expected TP-004 for assignee=bob:\n%s", out) + } +} + +func TestCmdNext_NoMatch(t *testing.T) { + // Repo with only done tasks — nothing to pick. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + + code := cmdNext(nil) + if code != 1 { + t.Errorf("cmdNext with only done tasks returned %d, want 1", code) + } +} + +func TestCmdNext_JSON(t *testing.T) { + // TP-004 is active — should be selected. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext([]string{"--json"}) + if code != 0 { + t.Fatalf("cmdNext --json returned %d", code) + } + }) + + var result map[string]interface{} + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if result["id"] != "TP-004" { + t.Errorf("JSON id = %v, want TP-004", result["id"]) + } + if result["reason"] == "" || result["reason"] == nil { + t.Errorf("JSON missing reason field: %v", result) + } +} diff --git a/cmd/docops/main.go b/cmd/docops/main.go index 317ca4f..a2695c7 100644 --- a/cmd/docops/main.go +++ b/cmd/docops/main.go @@ -35,6 +35,14 @@ func main() { os.Exit(cmdSchema(args[1:])) case "refresh": os.Exit(cmdRefresh(args[1:])) + case "get": + os.Exit(cmdGet(args[1:])) + case "list": + os.Exit(cmdList(args[1:])) + case "graph": + os.Exit(cmdGraph(args[1:])) + case "next": + os.Exit(cmdNext(args[1:])) default: fmt.Fprintf(os.Stderr, "docops: unknown command %q\n\n", args[0]) topLevelUsage(os.Stderr) @@ -56,10 +64,11 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, " new scaffold a new CTX/ADR/Task document") fmt.Fprintln(w, " schema (re)write docs/.docops/schema/*.schema.json") fmt.Fprintln(w, " refresh validate + index + state in one pass") + fmt.Fprintln(w, " get look up one doc by ID") + fmt.Fprintln(w, " list list docs with optional filters") + fmt.Fprintln(w, " graph typed edge graph from a starting doc") + fmt.Fprintln(w, " next recommend the next task to work on") fmt.Fprintln(w, " version print the build version") fmt.Fprintln(w, "") - fmt.Fprintln(w, "coming:") - fmt.Fprintln(w, " status, get, graph, review") - fmt.Fprintln(w, "") fmt.Fprintln(w, "see `docops --help` for per-command flags.") } diff --git a/docs/.index.json b/docs/.index.json index 1124df4..9e6261b 100644 --- a/docs/.index.json +++ b/docs/.index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-22T18:42:08Z", + "generated_at": "2026-04-22T19:12:49Z", "version": 1, "docs": [ { @@ -228,7 +228,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -447,7 +447,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -815,7 +815,7 @@ "edge": "requires" } ], - "implementation": "not-started", + "implementation": "done", "stale": false }, { @@ -960,7 +960,7 @@ ], "summary": "DocOps currently writes skills into two tool-specific directories during `docops init` and `docops upgrade`:", "word_count": 718, - "last_touched": "2026-04-22T18:41:28Z", + "last_touched": "2026-04-22T18:58:49Z", "age_days": 0, "referenced_by": [ { @@ -1451,9 +1451,9 @@ "folder": "docs/tasks", "path": "docs/tasks/TP-012-implement-read-commands.md", "title": "Implement focused read commands — `get`, `list`, `graph`, `next`", - "task_status": "backlog", + "task_status": "done", "priority": "p1", - "assignee": "unassigned", + "assignee": "nachiket", "requires": [ "ADR-0018", "ADR-0005", @@ -1644,7 +1644,7 @@ ], "summary": "Update `docops init` and `docops upgrade` to adopt the skills.sh canonical layout: skill files live in `.agents/skills/docops/`; tool-specific dirs (e.g. `.claude/skills/docops`) become symlinks. Per …", "word_count": 618, - "last_touched": "2026-04-22T18:42:01Z", + "last_touched": "2026-04-22T18:58:49Z", "age_days": 0, "stale": false } diff --git a/docs/STATE.md b/docs/STATE.md index 4606361..d016bd0 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -6,7 +6,7 @@ - Context: 4 active · 0 superseded - ADRs: 22 accepted · 0 draft · 0 superseded (21 `coverage: required`, 1 `coverage: not-needed`) -- Tasks: 4 backlog · 0 active · 0 blocked · 15 done +- Tasks: 3 backlog · 0 active · 0 blocked · 16 done ## Needs attention @@ -18,6 +18,7 @@ ## Recent activity +- 2026-04-23 1aa3e32 remove internal TP-xxx IDs from help output; fix AGENTS.md to not document unbuilt commands - 2026-04-22 f928497 TP-007: docops init — scaffold a DocOps-enabled repo - 2026-04-22 f5ab1e6 TP-004: docops index — enriched graph written to .index.json - 2026-04-22 f01bed5 TP-002: frontmatter schemas, validators, and JSON Schema emission @@ -37,5 +38,4 @@ - 2026-04-22 2b1a7ee planning: ADR-0021 + TP-018 — docops upgrade for in-place bumps - 2026-04-22 2a99403 TP-009: docops schema — JSON Schema for editor tooling - 2026-04-22 1a3de5c dog-food the DocOps convention (CTX, ADR, TP, meta/product split) -- 2026-04-22 13514b1 removed reference implementation diff --git a/docs/tasks/TP-012-implement-read-commands.md b/docs/tasks/TP-012-implement-read-commands.md index 900cf5e..aefb6f1 100644 --- a/docs/tasks/TP-012-implement-read-commands.md +++ b/docs/tasks/TP-012-implement-read-commands.md @@ -1,8 +1,8 @@ --- title: Implement focused read commands — `get`, `list`, `graph`, `next` -status: backlog +status: done priority: p1 -assignee: unassigned +assignee: nachiket requires: [ADR-0018, ADR-0005, ADR-0006, ADR-0010] depends_on: [TP-004] --- From 32cc60b8022b78005a331a74b7e5029def18eb35 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 00:54:00 +0530 Subject: [PATCH 02/11] =?UTF-8?q?TP-011:=20docops=20search=20=E2=80=94=20s?= =?UTF-8?q?ubstring/regex=20content=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships docops search with text match (title > tags > body ranking), structured filters (--kind/--status/--coverage/--tag/--priority/ --assignee/--since), --regex and --case flags, and --json output. Also returns root from bootstrapIndex (used by search for lazy body reads). 20 new tests including a dog-food pass against the live repo. Co-Authored-By: Claude Sonnet 4.6 --- cmd/docops/bootstrap.go | 19 +- cmd/docops/cmd_get.go | 2 +- cmd/docops/cmd_graph.go | 2 +- cmd/docops/cmd_list.go | 2 +- cmd/docops/cmd_next.go | 2 +- cmd/docops/cmd_search.go | 348 ++++++++++++++++++ cmd/docops/cmd_search_test.go | 327 ++++++++++++++++ cmd/docops/main.go | 3 + docs/.index.json | 14 +- docs/STATE.md | 4 +- docs/tasks/TP-011-implement-search-command.md | 4 +- 11 files changed, 703 insertions(+), 24 deletions(-) create mode 100644 cmd/docops/cmd_search.go create mode 100644 cmd/docops/cmd_search_test.go diff --git a/cmd/docops/bootstrap.go b/cmd/docops/bootstrap.go index 97fc6d8..b0ee61d 100644 --- a/cmd/docops/bootstrap.go +++ b/cmd/docops/bootstrap.go @@ -14,38 +14,39 @@ import ( // bootstrapIndex is the shared bootstrap sequence for read-only commands: // find config, load docs, validate, build in-memory index. -// On any failure it prints a prefixed error to stderr and returns code 2. -func bootstrapIndex(cmd string) (*index.Index, int) { +// Returns the index, the project root, and an exit code (0 = success, 2 = error). +// On any failure it prints a prefixed error to stderr. +func bootstrapIndex(cmd string) (*index.Index, string, int) { cwd, err := os.Getwd() if err != nil { fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) - return nil, 2 + return nil, "", 2 } cfg, root, err := config.FindAndLoad(cwd) if err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Fprintf(os.Stderr, "docops %s: no docops.yaml found — run `docops init` first\n", cmd) - return nil, 2 + return nil, "", 2 } fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) - return nil, 2 + return nil, "", 2 } set, err := loader.Load(root, cfg) if err != nil { fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) - return nil, 2 + return nil, "", 2 } report := validator.Validate(set, cfg) if !report.OK() { fmt.Fprintf(os.Stderr, "docops %s: refusing: %d validation error(s); run 'docops validate'\n", cmd, len(report.Errors)) - return nil, 2 + return nil, "", 2 } idx, err := index.Build(set, cfg, root, time.Now()) if err != nil { fmt.Fprintf(os.Stderr, "docops %s: build index: %v\n", cmd, err) - return nil, 2 + return nil, "", 2 } - return idx, 0 + return idx, root, 0 } // indexLookup finds a doc by ID. diff --git a/cmd/docops/cmd_get.go b/cmd/docops/cmd_get.go index 1b734ab..587b440 100644 --- a/cmd/docops/cmd_get.go +++ b/cmd/docops/cmd_get.go @@ -38,7 +38,7 @@ func cmdGet(args []string) int { } id := fs.Arg(0) - idx, code := bootstrapIndex("get") + idx, _, code := bootstrapIndex("get") if code != 0 { return code } diff --git a/cmd/docops/cmd_graph.go b/cmd/docops/cmd_graph.go index 9f4a991..df11f11 100644 --- a/cmd/docops/cmd_graph.go +++ b/cmd/docops/cmd_graph.go @@ -56,7 +56,7 @@ func cmdGraph(args []string) int { *depth = 0 } - idx, code := bootstrapIndex("graph") + idx, _, code := bootstrapIndex("graph") if code != 0 { return code } diff --git a/cmd/docops/cmd_list.go b/cmd/docops/cmd_list.go index b6eb4ff..8bf9d38 100644 --- a/cmd/docops/cmd_list.go +++ b/cmd/docops/cmd_list.go @@ -54,7 +54,7 @@ func cmdList(args []string) int { return 2 } - idx, code := bootstrapIndex("list") + idx, _, code := bootstrapIndex("list") if code != 0 { return code } diff --git a/cmd/docops/cmd_next.go b/cmd/docops/cmd_next.go index f6af8e4..e207a0b 100644 --- a/cmd/docops/cmd_next.go +++ b/cmd/docops/cmd_next.go @@ -45,7 +45,7 @@ func cmdNext(args []string) int { return 2 } - idx, code := bootstrapIndex("next") + idx, _, code := bootstrapIndex("next") if code != 0 { return code } diff --git a/cmd/docops/cmd_search.go b/cmd/docops/cmd_search.go new file mode 100644 index 0000000..97a72aa --- /dev/null +++ b/cmd/docops/cmd_search.go @@ -0,0 +1,348 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/logicwind/docops/internal/index" + "github.com/logicwind/docops/internal/schema" +) + +// stringList is a repeatable flag value (used for --tag). +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ", ") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +// searchResult is one match returned by cmdSearch. +type searchResult struct { + ID string `json:"id"` + Path string `json:"path"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status,omitempty"` + Snippet string `json:"snippet"` + MatchField string `json:"match_field"` // "title", "tags", "body", or "" + + // unexported ranking keys + rank int // 1=title 2=tags 3=body-first-para 4=body-later 0=filter-only + lastTouched string +} + +// cmdSearch implements `docops search [flags] [--json]`. +// Exit codes: +// +// 0 always (no matches is not an error) +// 2 bootstrap error, bad usage, or invalid regex +func cmdSearch(args []string) int { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit matches as JSON array") + useRegex := fs.Bool("regex", false, "treat query as a regexp pattern") + caseSensitive := fs.Bool("case", false, "case-sensitive match (default is case-insensitive)") + kindFlag := fs.String("kind", "", "filter by kind: CTX, ADR, TP") + statusFlag := fs.String("status", "", "filter by status (per-kind)") + coverage := fs.String("coverage", "", "filter ADRs by coverage: required, not-needed") + var tags stringList + fs.Var(&tags, "tag", "filter ADRs by tag (repeatable; all tags must match)") + priority := fs.String("priority", "", "filter Tasks by priority: p0, p1, p2") + assignee := fs.String("assignee", "", "filter Tasks by assignee") + since := fs.String("since", "", "filter by last_touched >= YYYY-MM-DD") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops search [] [flags]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + query := "" + if fs.NArg() > 0 { + query = strings.Join(fs.Args(), " ") + } + + kindUpper := strings.ToUpper(*kindFlag) + + // Guard: empty query requires at least one structured filter. + hasFilter := kindUpper != "" || *statusFlag != "" || *coverage != "" || + len(tags) > 0 || *priority != "" || *assignee != "" || *since != "" + if query == "" && !hasFilter { + fmt.Fprintln(os.Stderr, "docops search: provide a query or at least one filter flag") + fs.Usage() + return 2 + } + + // Guard: kind-incompatible filter combos. + if kindUpper == "CTX" && *statusFlag != "" { + fmt.Fprintln(os.Stderr, "docops search: --status is not valid for --kind CTX (CTX has no status field)") + return 2 + } + if *coverage != "" && kindUpper != "" && kindUpper != "ADR" { + fmt.Fprintln(os.Stderr, "docops search: --coverage is only valid for ADR") + return 2 + } + + // Compile matcher. + m, err := newMatcher(query, *useRegex, *caseSensitive) + if err != nil { + fmt.Fprintf(os.Stderr, "docops search: invalid regex %q: %v\n", query, err) + return 2 + } + + // Parse --since. + sinceStr := *since // YYYY-MM-DD prefix comparison against RFC3339 + + idx, root, code := bootstrapIndex("search") + if code != 0 { + return code + } + + var results []searchResult + for _, doc := range idx.Docs { + // Structured filters. + if kindUpper != "" && doc.Kind != kindUpper { + continue + } + if *statusFlag != "" && docStatusField(doc) != *statusFlag { + continue + } + if *coverage != "" && doc.ADRCoverage != *coverage { + continue + } + for _, t := range tags { + if !sliceContains(doc.ADRTags, t) { + goto nextDoc + } + } + if *priority != "" && doc.TaskPriority != *priority { + continue + } + if *assignee != "" && doc.TaskAssignee != *assignee { + continue + } + if sinceStr != "" && doc.LastTouched[:10] < sinceStr { + continue + } + + // Text match (skip if no query → filter-only, include all). + if query == "" { + results = append(results, searchResult{ + ID: doc.ID, Path: doc.Path, Kind: doc.Kind, Title: doc.CTXTitle, + Status: docStatusField(doc), + Snippet: doc.Summary, + MatchField: "", + rank: 0, + lastTouched: doc.LastTouched, + }) + continue + } + + if r := matchDoc(doc, root, m); r != nil { + results = append(results, *r) + } + nextDoc: + } + + // Sort: rank asc, lastTouched desc, id asc. + sort.SliceStable(results, func(i, j int) bool { + if results[i].rank != results[j].rank { + return results[i].rank < results[j].rank + } + if results[i].lastTouched != results[j].lastTouched { + return results[i].lastTouched > results[j].lastTouched + } + return results[i].ID < results[j].ID + }) + + if *asJSON { + out := make([]searchResult, len(results)) + copy(out, results) + if out == nil { + out = []searchResult{} + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops search: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + for _, r := range results { + kindStatus := r.Kind + if r.Status != "" { + kindStatus += "/" + r.Status + } + fmt.Printf("%s %-14s %s\n", r.ID, kindStatus, r.Title) + if r.Snippet != "" { + fmt.Printf(" (%s) %s\n", fieldLabel(r.MatchField), r.Snippet) + } + } + fmt.Printf("%d match(es)\n", len(results)) + return 0 +} + +// matcher encapsulates the text-match logic. +type matcher struct { + query string + re *regexp.Regexp + useRegex bool + caseSensitive bool +} + +func newMatcher(query string, useRegex, caseSensitive bool) (*matcher, error) { + if query == "" { + return &matcher{}, nil + } + m := &matcher{query: query, useRegex: useRegex, caseSensitive: caseSensitive} + if useRegex { + pattern := query + if !caseSensitive { + pattern = "(?i)" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + m.re = re + } + return m, nil +} + +// findIn returns the byte position of the first match in text, or -1. +func (m *matcher) findIn(text string) int { + if m.query == "" { + return -1 + } + if m.useRegex { + loc := m.re.FindStringIndex(text) + if loc == nil { + return -1 + } + return loc[0] + } + q, t := m.query, text + if !m.caseSensitive { + q = strings.ToLower(q) + t = strings.ToLower(t) + } + return strings.Index(t, q) +} + +// matchDoc tries to match doc against the matcher, reading the body lazily. +// Returns nil if no match. +func matchDoc(doc index.IndexedDoc, root string, m *matcher) *searchResult { + base := searchResult{ + ID: doc.ID, Path: doc.Path, Kind: doc.Kind, Title: doc.CTXTitle, + Status: docStatusField(doc), + lastTouched: doc.LastTouched, + } + + // 1. Title. + if pos := m.findIn(doc.CTXTitle); pos >= 0 { + r := base + r.Snippet = doc.CTXTitle + r.MatchField = "title" + r.rank = 1 + return &r + } + + // 2. Tags (any tag containing the query). + for _, tag := range doc.ADRTags { + if m.findIn(tag) >= 0 { + r := base + r.Snippet = strings.Join(doc.ADRTags, ", ") + r.MatchField = "tags" + r.rank = 2 + return &r + } + } + + // 3. Body (lazy read). + raw, err := os.ReadFile(filepath.Join(root, doc.Path)) + if err != nil { + return nil + } + _, bodyBytes, err := schema.SplitFrontmatter(raw) + if err != nil { + bodyBytes = raw + } + body := string(bodyBytes) + + pos := m.findIn(body) + if pos < 0 { + return nil + } + + r := base + r.MatchField = "body" + r.Snippet = extractSnippet(body, pos, 120) + if isFirstParagraph(body, pos) { + r.rank = 3 + } else { + r.rank = 4 + } + return &r +} + +// isFirstParagraph reports whether pos falls within the first paragraph +// (content before the first blank line). +func isFirstParagraph(body string, pos int) bool { + end := strings.Index(body, "\n\n") + if end < 0 { + return true + } + return pos < end +} + +// extractSnippet extracts ~maxLen chars centred on matchPos, with "…" padding. +func extractSnippet(body string, matchPos, maxLen int) string { + half := maxLen / 2 + start := matchPos - half + if start < 0 { + start = 0 + } + end := matchPos + half + if end > len(body) { + end = len(body) + } + // Snap to valid rune boundaries. + for start > 0 && !utf8.RuneStart(body[start]) { + start-- + } + for end < len(body) && !utf8.RuneStart(body[end]) { + end++ + } + + snippet := body[start:end] + snippet = strings.ReplaceAll(snippet, "\n", " ") + snippet = strings.Join(strings.Fields(snippet), " ") // collapse whitespace + snippet = strings.TrimSpace(snippet) + + if start > 0 { + snippet = "…" + snippet + } + if end < len(body) { + snippet += "…" + } + return snippet +} + +func fieldLabel(f string) string { + if f == "" { + return "filter" + } + return f +} diff --git a/cmd/docops/cmd_search_test.go b/cmd/docops/cmd_search_test.go new file mode 100644 index 0000000..09350f3 --- /dev/null +++ b/cmd/docops/cmd_search_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// plantSearchDocs builds a tree with content suitable for search tests. +// CTX-001 (prd) — "CLI substrate" +// ADR-0001 — "Use Go for implementation" tags:[cli, go] +// ADR-0002 — "Rate limiting strategy" tags:[api, rate-limit] +// TP-001 — done, alice, "Scaffold the CLI" requires ADR-0001 +// TP-002 — backlog, "Implement rate limiter" requires ADR-0002 +func plantSearchDocs(t *testing.T) string { + t.Helper() + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/context/CTX-001-cli-substrate.md", validCTX) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/decisions/ADR-0002-rate-limit.md", `--- +title: Rate limiting strategy +status: draft +coverage: required +date: 2026-01-02 +supersedes: [] +related: [] +tags: [api, rate-limit] +--- + +# Rate limiting strategy + +## Context + +We need to protect our API endpoints from abuse. + +## Decision + +Token bucket algorithm for rate limiting. +`) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-rate-limiter.md", `--- +title: Implement rate limiter +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0002] +depends_on: [] +--- + +# Implement rate limiter + +## Goal + +Ship the token bucket rate limiter. + +## Acceptance + +## Notes +`) + return root +} + +// ── basic text match ───────────────────────────────────────────────────────── + +func TestCmdSearch_TitleMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"Go"}) + if code != 0 { + t.Fatalf("cmdSearch returned %d", code) + } + }) + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (title 'Use Go') missing:\n%s", out) + } + if !strings.Contains(out, "title") { + t.Errorf("match_field 'title' missing from output:\n%s", out) + } +} + +func TestCmdSearch_TagMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate-limit"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (tag 'rate-limit') missing:\n%s", out) + } + if !strings.Contains(out, "tags") { + t.Errorf("match_field 'tags' missing:\n%s", out) + } +} + +func TestCmdSearch_BodyMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"token bucket"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (body 'token bucket') missing:\n%s", out) + } + if !strings.Contains(out, "body") { + t.Errorf("match_field 'body' missing:\n%s", out) + } +} + +func TestCmdSearch_CaseInsensitiveDefault(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"go for implementation"}) + }) + if !strings.Contains(out, "ADR-0001") { + t.Errorf("case-insensitive match failed:\n%s", out) + } +} + +func TestCmdSearch_CaseSensitive(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--case", "USE GO"}) // uppercase won't match lowercase title + }) + if strings.Contains(out, "ADR-0001") { + t.Errorf("case-sensitive search should not match 'Use Go' with 'USE GO':\n%s", out) + } +} + +func TestCmdSearch_RegexMode(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--regex", "rat(e|ing)"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("regex match failed:\n%s", out) + } +} + +func TestCmdSearch_InvalidRegex(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch([]string{"--regex", "["}) + if code != 2 { + t.Errorf("invalid regex should return 2, got %d", code) + } +} + +// ── structured filters ─────────────────────────────────────────────────────── + +func TestCmdSearch_KindFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--kind", "ADR", "rate"}) + }) + if strings.Contains(out, "TP-") { + t.Errorf("TP docs should be excluded by --kind ADR:\n%s", out) + } + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 should match:\n%s", out) + } +} + +func TestCmdSearch_StatusFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--kind", "ADR", "--status", "draft", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (draft) missing:\n%s", out) + } + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (accepted) should be excluded:\n%s", out) + } +} + +func TestCmdSearch_CoverageFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--coverage", "required", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 missing with coverage=required:\n%s", out) + } +} + +func TestCmdSearch_TagStructuredFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--tag", "api", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 missing with --tag api:\n%s", out) + } + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 should be excluded (no api tag):\n%s", out) + } +} + +func TestCmdSearch_FilterOnly(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"--kind", "ADR"}) + if code != 0 { + t.Fatalf("filter-only returned %d", code) + } + }) + if !strings.Contains(out, "ADR-0001") || !strings.Contains(out, "ADR-0002") { + t.Errorf("both ADRs should appear in filter-only:\n%s", out) + } +} + +func TestCmdSearch_NoQueryNoFilter(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch(nil) + if code != 2 { + t.Errorf("no query no filter should return 2, got %d", code) + } +} + +func TestCmdSearch_CTXStatusError(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch([]string{"--kind", "CTX", "--status", "accepted", "cli"}) + if code != 2 { + t.Errorf("CTX+status should return 2, got %d", code) + } +} + +// ── ranking ────────────────────────────────────────────────────────────────── + +func TestCmdSearch_RankingTitleBeforeBody(t *testing.T) { + // "Rate limiting" appears in ADR-0002 title AND body. + // Title match should rank first. + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate"}) + }) + // ADR-0002 should appear; its match should be "title" not "body" + if !strings.Contains(out, "ADR-0002") { + t.Fatalf("ADR-0002 missing:\n%s", out) + } + // In the output, ADR-0002's line should show (title) snippet + lines := strings.Split(out, "\n") + for i, l := range lines { + if strings.Contains(l, "ADR-0002") && i+1 < len(lines) { + if strings.Contains(lines[i+1], "(title)") { + return // pass + } + } + } + t.Errorf("expected (title) match for ADR-0002:\n%s", out) +} + +// ── output shapes ──────────────────────────────────────────────────────────── + +func TestCmdSearch_JSON(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"--json", "rate"}) + if code != 0 { + t.Fatalf("--json returned %d", code) + } + }) + var results []searchResult + if err := json.Unmarshal([]byte(out), &results); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if len(results) == 0 { + t.Error("expected at least one result") + } + for _, r := range results { + if r.ID == "" || r.Kind == "" || r.MatchField == "" { + t.Errorf("result missing required fields: %+v", r) + } + } +} + +func TestCmdSearch_JSONEmptyOnNoMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--json", "xyzzy-no-match-anywhere"}) + }) + var results []searchResult + if err := json.Unmarshal([]byte(out), &results); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if len(results) != 0 { + t.Errorf("expected empty array, got %d results", len(results)) + } +} + +func TestCmdSearch_SummaryLine(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate"}) + }) + if !strings.Contains(out, "match(es)") { + t.Errorf("missing trailing 'N match(es)' line:\n%s", out) + } +} + +func TestCmdSearch_NoConfig(t *testing.T) { + root := t.TempDir() + orig, _ := os.Getwd() + _ = os.Chdir(root) + t.Cleanup(func() { _ = os.Chdir(orig) }) + + code := cmdSearch([]string{"anything"}) + if code != 2 { + t.Errorf("no config should return 2, got %d", code) + } +} + +// ── dog-food ───────────────────────────────────────────────────────────────── + +func TestCmdSearch_Dogfood(t *testing.T) { + // Run against the real project repo. Only works when cwd is the repo root. + orig, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(orig) }) + + out := captureStdout(t, func() { + code := cmdSearch([]string{"ADR-0018"}) + if code != 0 { + t.Fatalf("dog-food search returned %d", code) + } + }) + if !strings.Contains(out, "match(es)") { + t.Errorf("dog-food: missing summary line:\n%s", out) + } +} diff --git a/cmd/docops/main.go b/cmd/docops/main.go index a2695c7..62fc697 100644 --- a/cmd/docops/main.go +++ b/cmd/docops/main.go @@ -43,6 +43,8 @@ func main() { os.Exit(cmdGraph(args[1:])) case "next": os.Exit(cmdNext(args[1:])) + case "search": + os.Exit(cmdSearch(args[1:])) default: fmt.Fprintf(os.Stderr, "docops: unknown command %q\n\n", args[0]) topLevelUsage(os.Stderr) @@ -68,6 +70,7 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, " list list docs with optional filters") fmt.Fprintln(w, " graph typed edge graph from a starting doc") fmt.Fprintln(w, " next recommend the next task to work on") + fmt.Fprintln(w, " search substring/regex search over title, tags, and body") fmt.Fprintln(w, " version print the build version") fmt.Fprintln(w, "") fmt.Fprintln(w, "see `docops --help` for per-command flags.") diff --git a/docs/.index.json b/docs/.index.json index 9e6261b..c0caad6 100644 --- a/docs/.index.json +++ b/docs/.index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-22T19:12:49Z", + "generated_at": "2026-04-22T19:23:52Z", "version": 1, "docs": [ { @@ -88,7 +88,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -282,7 +282,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -773,7 +773,7 @@ "edge": "requires" } ], - "implementation": "not-started", + "implementation": "done", "stale": false }, { @@ -1428,9 +1428,9 @@ "folder": "docs/tasks", "path": "docs/tasks/TP-011-implement-search-command.md", "title": "Implement `docops search` — content + structured filter query", - "task_status": "backlog", + "task_status": "done", "priority": "p1", - "assignee": "unassigned", + "assignee": "nachiket", "requires": [ "ADR-0017", "ADR-0002", @@ -1465,7 +1465,7 @@ ], "summary": "Close the query-surface gap identified in ADR-0018 so agents never need to `cat docs/.index.json` to answer routine questions. Each command returns a focused slice of the index, not the whole graph.", "word_count": 494, - "last_touched": "2026-04-22T08:19:55Z", + "last_touched": "2026-04-22T19:13:03Z", "age_days": 0, "stale": false }, diff --git a/docs/STATE.md b/docs/STATE.md index d016bd0..8c0d78e 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -6,7 +6,7 @@ - Context: 4 active · 0 superseded - ADRs: 22 accepted · 0 draft · 0 superseded (21 `coverage: required`, 1 `coverage: not-needed`) -- Tasks: 3 backlog · 0 active · 0 blocked · 16 done +- Tasks: 2 backlog · 0 active · 0 blocked · 17 done ## Needs attention @@ -18,6 +18,7 @@ ## Recent activity +- 2026-04-23 6f41758 TP-012: docops get/list/graph/next — focused read commands - 2026-04-23 1aa3e32 remove internal TP-xxx IDs from help output; fix AGENTS.md to not document unbuilt commands - 2026-04-22 f928497 TP-007: docops init — scaffold a DocOps-enabled repo - 2026-04-22 f5ab1e6 TP-004: docops index — enriched graph written to .index.json @@ -37,5 +38,4 @@ - 2026-04-22 4631e43 TP-017: docops new --body + validator enum hints - 2026-04-22 2b1a7ee planning: ADR-0021 + TP-018 — docops upgrade for in-place bumps - 2026-04-22 2a99403 TP-009: docops schema — JSON Schema for editor tooling -- 2026-04-22 1a3de5c dog-food the DocOps convention (CTX, ADR, TP, meta/product split) diff --git a/docs/tasks/TP-011-implement-search-command.md b/docs/tasks/TP-011-implement-search-command.md index c7cdadb..344b121 100644 --- a/docs/tasks/TP-011-implement-search-command.md +++ b/docs/tasks/TP-011-implement-search-command.md @@ -1,8 +1,8 @@ --- title: Implement `docops search` — content + structured filter query -status: backlog +status: done priority: p1 -assignee: unassigned +assignee: nachiket requires: [ADR-0017, ADR-0002, ADR-0006] depends_on: [TP-004] --- From 7656d0842ed834950624bb40fef988243e605295 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 01:18:15 +0530 Subject: [PATCH 03/11] =?UTF-8?q?planning:=20ADR-0023=20+=20TP-020/TP-021?= =?UTF-8?q?=20=E2=80=94=20split=20TP-018=20into=20refactor=20+=20upgrader?= =?UTF-8?q?=20+=20update-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0023 captures the gstack-shaped update-check pattern: cached probe of remote VERSION, fail-quiet on network errors, hooked into upgrade so users learn before syncing stale templates. TP-020 carves out the internal/scaffold/ refactor as a standalone, behavior-preserving step so TP-018 (upgrader) only diffs the genuinely new code. TP-021 owns the update-check implementation. Co-Authored-By: Claude Sonnet 4.6 --- docs/.docops/counters.json | 4 +- docs/.index.json | 112 ++++++++++++- docs/STATE.md | 6 +- ...check-cached-lazy-opt-out-version-probe.md | 156 ++++++++++++++++++ ...t-docops-upgrade-targeted-scaffold-sync.md | 4 +- ...ernal-scaffold-from-internal-initter-re.md | 68 ++++++++ ...update-check-cached-gstack-style-docops.md | 128 ++++++++++++++ 7 files changed, 467 insertions(+), 11 deletions(-) create mode 100644 docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md create mode 100644 docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md create mode 100644 docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md diff --git a/docs/.docops/counters.json b/docs/.docops/counters.json index 191ffa6..53b1233 100644 --- a/docs/.docops/counters.json +++ b/docs/.docops/counters.json @@ -1,8 +1,8 @@ { "version": 1, "next": { - "ADR": 22, + "ADR": 24, "CTX": 5, - "TP": 19 + "TP": 22 } } diff --git a/docs/.index.json b/docs/.index.json index c0caad6..54d6618 100644 --- a/docs/.index.json +++ b/docs/.index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-22T19:23:52Z", + "generated_at": "2026-04-22T19:47:51Z", "version": 1, "docs": [ { @@ -844,6 +844,10 @@ "last_touched": "2026-04-22T12:30:23Z", "age_days": 0, "referenced_by": [ + { + "id": "ADR-0023", + "edge": "related" + }, { "id": "TP-013", "edge": "requires" @@ -931,9 +935,17 @@ "id": "ADR-0022", "edge": "related" }, + { + "id": "ADR-0023", + "edge": "related" + }, { "id": "TP-018", "edge": "requires" + }, + { + "id": "TP-020", + "edge": "requires" } ], "implementation": "not-started", @@ -971,6 +983,40 @@ "implementation": "not-started", "stale": false }, + { + "id": "ADR-0023", + "kind": "ADR", + "folder": "docs/decisions", + "path": "docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md", + "title": "Update-check — cached, lazy, opt-out version probe", + "status": "draft", + "coverage": "required", + "date": "2026-04-23", + "related": [ + "ADR-0021", + "ADR-0019" + ], + "summary": "DocOps ships through Homebrew, Scoop, and direct binary downloads. Once a user runs `docops init`, their project files (skills, schemas, AGENTS.md block) drift from upstream every time we cut a releas…", + "word_count": 909, + "last_touched": "2026-04-22T19:45:27Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-018", + "edge": "requires" + }, + { + "id": "TP-020", + "edge": "requires" + }, + { + "id": "TP-021", + "edge": "requires" + } + ], + "implementation": "not-started", + "stale": false + }, { "id": "CTX-001", "kind": "CTX", @@ -1441,7 +1487,7 @@ ], "summary": "Ship a single command that answers \"has this been discussed before?\" — substring or regex match over title / tags / body, composable with structured frontmatter filters. Output is the focused slice an…", "word_count": 345, - "last_touched": "2026-04-22T08:19:55Z", + "last_touched": "2026-04-22T19:24:00Z", "age_days": 0, "stale": false }, @@ -1604,12 +1650,15 @@ "priority": "p2", "assignee": "unassigned", "requires": [ - "ADR-0021" + "ADR-0021", + "ADR-0023" ], "depends_on": [ "TP-007", "TP-009", - "TP-013" + "TP-013", + "TP-020", + "TP-021" ], "summary": "Ship the `docops upgrade` subcommand that pulls the current binary's shipped templates into an already-initialized project without clobbering `docops.yaml` or the pre-commit hook, per ADR-0021.", "word_count": 743, @@ -1647,6 +1696,61 @@ "last_touched": "2026-04-22T18:58:49Z", "age_days": 0, "stale": false + }, + { + "id": "TP-020", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md", + "title": "Extract internal/scaffold/ from internal/initter/ (refactor)", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0021", + "ADR-0023" + ], + "summary": "Lift the helpers that `docops upgrade` will need to share with `docops init` out of `internal/initter/` and into a new `internal/scaffold/` package, with **zero behavior change**. Sets up TP-018 to la…", + "word_count": 370, + "last_touched": "2026-04-22T19:47:02Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-018", + "edge": "depends_on" + } + ], + "blocks": [ + "TP-018" + ], + "stale": false + }, + { + "id": "TP-021", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md", + "title": "Implement update-check (cached, gstack-style) + docops update-check subcommand", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0023" + ], + "summary": "Ship the update-check mechanism specified by ADR-0023: a small internal package, a standalone `docops update-check` subcommand, and integration into `docops upgrade` so users learn when their binary i…", + "word_count": 647, + "last_touched": "2026-04-22T19:47:37Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-018", + "edge": "depends_on" + } + ], + "blocks": [ + "TP-018" + ], + "stale": false } ] } diff --git a/docs/STATE.md b/docs/STATE.md index 8c0d78e..8dfc981 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -5,8 +5,8 @@ ## Counts - Context: 4 active · 0 superseded -- ADRs: 22 accepted · 0 draft · 0 superseded (21 `coverage: required`, 1 `coverage: not-needed`) -- Tasks: 2 backlog · 0 active · 0 blocked · 17 done +- ADRs: 22 accepted · 1 draft · 0 superseded (22 `coverage: required`, 1 `coverage: not-needed`) +- Tasks: 4 backlog · 0 active · 0 blocked · 17 done ## Needs attention @@ -19,6 +19,7 @@ ## Recent activity - 2026-04-23 6f41758 TP-012: docops get/list/graph/next — focused read commands +- 2026-04-23 32cc60b TP-011: docops search — substring/regex content search - 2026-04-23 1aa3e32 remove internal TP-xxx IDs from help output; fix AGENTS.md to not document unbuilt commands - 2026-04-22 f928497 TP-007: docops init — scaffold a DocOps-enabled repo - 2026-04-22 f5ab1e6 TP-004: docops index — enriched graph written to .index.json @@ -37,5 +38,4 @@ - 2026-04-22 50eeb7a initial docs & idea - 2026-04-22 4631e43 TP-017: docops new --body + validator enum hints - 2026-04-22 2b1a7ee planning: ADR-0021 + TP-018 — docops upgrade for in-place bumps -- 2026-04-22 2a99403 TP-009: docops schema — JSON Schema for editor tooling diff --git a/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md b/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md new file mode 100644 index 0000000..6b6dd1f --- /dev/null +++ b/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md @@ -0,0 +1,156 @@ +--- +title: Update-check — cached, lazy, opt-out version probe +status: draft +coverage: required +date: "2026-04-23" +supersedes: [] +related: [ADR-0021, ADR-0019] +tags: [] +--- + +## Context + +DocOps ships through Homebrew, Scoop, and direct binary downloads. Once +a user runs `docops init`, their project files (skills, schemas, +AGENTS.md block) drift from upstream every time we cut a release that +touches templates. ADR-0021 / TP-018 introduce `docops upgrade` to sync +those files in place — but `docops upgrade` only knows about the binary +the user already has installed. If their binary is itself stale (e.g. +the user is on `docops v0.1.1` but `v0.1.2` shipped two weeks ago with +new skill files), `docops upgrade` will quietly install the v0.1.1 +templates and the user will think they are current. + +The user has no in-band way to learn that their binary is behind. The +fix is to add a periodic update check, the same way `gstack` does: +cache the result in `~/.docops/`, only hit the network when the cache +is stale, fail quiet, and surface a one-line reminder in commands that +naturally care (`upgrade` first, others later). + +## Decision + +Ship a small `internal/updatecheck/` package and one new subcommand +`docops update-check`. Wire the package into `docops upgrade` so a +stale binary surfaces before any in-place template sync runs. + +### Contract + +`docops update-check` prints exactly one of: + +- `UP_TO_DATE ` — local matches remote. +- `UPGRADE_AVAILABLE ` — remote ahead. +- (nothing) — check skipped (snoozed, disabled, offline, dev build). + +Exit code is always `0`. The output line is intentionally +shell-parseable; this matches gstack and lets users wire it into shell +prompts, MOTDs, or CI guardrails. + +### Caching + +Cache file: `~/.docops/last-update-check`. Format: the same one-line +shape as the `update-check` output above (`UP_TO_DATE ` or +`UPGRADE_AVAILABLE `). Two TTLs: + +- `UP_TO_DATE`: **6 hours**. We re-check periodically so newly + shipped releases surface within a working day. +- `UPGRADE_AVAILABLE`: **24 hours**. The user already knows; we + don't need to re-fetch as eagerly, but we do want to keep nagging + on the next day's first invocation. + +Cache is per-user, not per-project. Multiple projects share one cache +file because the binary is global. + +### Remote source + +Primary: `https://raw.githubusercontent.com/logicwind/docops/main/VERSION`. +A `VERSION` file is added to the repo root and bumped during release +(or derived from the last git tag — implementer's call, see TP-021). +Validation: response must match `^[0-9]+\.[0-9]+\.[0-9]+$`. Anything +else is treated as a network error and the cache is updated with +`UP_TO_DATE` so we don't loop on transient failures. + +Timeout: 5 seconds. On any network error or invalid response, fail +silently and write `UP_TO_DATE ` to the cache so the user is +never blocked or spammed by an unreachable upstream. + +### Snooze + +A user can snooze a specific available upgrade by writing +`~/.docops/update-snoozed` with ` ` +(matches gstack's format). Levels: 1=24h, 2=48h, 3+=7d. A new remote +version invalidates any active snooze. The snooze file is written by +`docops update-check --snooze` (level auto-increments each invocation). + +### Opt-out + +Two ways to disable: + +- Per-user: `~/.docops/update-snoozed` containing `disabled` (or any + invalid version string with `level=999`). Treated as permanent + snooze. +- Per-invocation env: `DOCOPS_UPDATE_CHECK=off` skips the check. + Honored by both the standalone subcommand and the internal + piggyback in `docops upgrade`. + +Dev builds (version starts with `dev` or contains `+dirty`) skip the +check unconditionally — there is no meaningful "remote" for them. + +### Integration with `docops upgrade` + +Before printing the upgrade plan, `docops upgrade` calls +`updatecheck.Run()`. If it returns `UPGRADE_AVAILABLE`, we print a +warning block: + +``` +Warning: docops v0.1.1 is installed; v0.1.2 is available. + Run `brew upgrade docops` (or your package manager's + equivalent) before `docops upgrade`, or you'll sync the + older templates. + +Continue with v0.1.1 templates anyway? [y/N] +``` + +`--yes` skips the warning prompt the same way it skips the main +confirm. `UP_TO_DATE` and silent results print nothing. + +No other subcommand fires the check automatically. Scripts that pipe +`docops list` / `docops get` / `docops search` should not pay a +network or stat-syscall cost they didn't ask for. Users who want +proactive nagging can run `docops update-check` from their shell +profile. + +## Rationale + +- A standalone subcommand keeps the behavior auditable and scriptable + — power users can opt in to extra nagging without us baking it into + every code path. +- Caching with multi-hour TTLs means `docops upgrade` stays + near-instant on warm cache and at most one 5s network hit on cold + cache. The two-tier TTL (shorter for up-to-date, longer for + upgrade-available) mirrors gstack's empirically-chosen pattern. +- Failing quiet on network errors is non-negotiable: docops runs in + pre-commit hooks and CI environments where flaky DNS would + otherwise break commits. +- Hooking only into `docops upgrade` and not every command keeps the + blast radius small. We can add more integration points later if + users ask for them. +- `~/.docops/` as a state dir mirrors `~/.gstack/`, `~/.aws/`, + `~/.config/gh/`. No XDG variant for v1; if users object, we can + honor `$XDG_STATE_HOME` in a follow-up. + +## Consequences + +- A new state directory `~/.docops/` is created lazily the first time + `update-check` runs. It must not be required for any other docops + command to function — packaging and CI environments without a + writable home directory must still work. +- The repository now ships a `VERSION` file at the root that must be + kept in sync with release tags. Goreleaser config gets a step (or + the maintainer remembers — TP-021 picks the cheaper option). +- `docops upgrade` gains an interactive prompt path that fires when + the binary is stale. CI must always pass `--yes` to avoid hanging. +- A future ADR may extend update-check to fire from `docops init` + (so first-time users learn they're already a release behind), but + that is out of scope here. +- Telemetry: we deliberately do **not** ping any upstream beyond the + one raw-content fetch. No counts, no UA, no install ID. If we want + install metrics later, that needs its own ADR. diff --git a/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md b/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md index b8ec4f5..b97bc8b 100644 --- a/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md +++ b/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md @@ -3,8 +3,8 @@ title: Implement docops upgrade — targeted scaffold sync status: backlog priority: p2 assignee: unassigned -requires: [ADR-0021] -depends_on: [TP-007, TP-009, TP-013] +requires: [ADR-0021, ADR-0023] +depends_on: [TP-007, TP-009, TP-013, TP-020, TP-021] --- # Implement docops upgrade — targeted scaffold sync diff --git a/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md b/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md new file mode 100644 index 0000000..1b2675e --- /dev/null +++ b/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md @@ -0,0 +1,68 @@ +--- +title: Extract internal/scaffold/ from internal/initter/ (refactor) +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0021, ADR-0023] +depends_on: [] +--- + +## Goal + +Lift the helpers that `docops upgrade` will need to share with +`docops init` out of `internal/initter/` and into a new +`internal/scaffold/` package, with **zero behavior change**. Sets up +TP-018 to land cleanly without copy-pasting code. + +## Acceptance + +- New package `internal/scaffold/` with these moves from + `internal/initter/initter.go`: + - `Action` struct → `scaffold.Action` (verbatim). + - `printPlan(w, actions, dry)` → `scaffold.PrintPlan` (exported). + - `mergeAgentsBlock(existing, tmpl)` → `scaffold.MergeAgentsBlock`. + - `extractBlock(tmpl)` → `scaffold.ExtractBlock`. + - The `` / `` marker + constants → exported constants in `scaffold/`. + - `dirAction(opts, rel)` → `scaffold.DirAction(rootAbs, rel)` — + flatten the `Options` dependency to just the root path so + upgrader can call it without an initter.Options. + - `fileAction(opts, rel, body, mode)` → `scaffold.FileAction(rootAbs, + rel, body, mode, force)` — same flattening; pass the force flag + explicitly instead of reading it from an Options struct. +- New helper `scaffold.LoadShippedSkills() (map[string][]byte, error)` + that wraps `templates.Skills()` and returns the same map. Both + initter and upgrader call this. +- `internal/initter/` re-imports from `internal/scaffold/` and is + reduced accordingly. The public surface of `initter` (the `Run` + function, `Options`, `Result`) is unchanged. +- `cmd/docops/cmd_init.go` is **not modified**. +- Existing tests pass unchanged: + - `go test ./internal/initter/...` + - `go test ./cmd/docops/...` (`cmd_init_test.go` in particular). + - `go test ./...` overall green. +- New file `internal/scaffold/scaffold_test.go` covers the moved + helpers directly: `MergeAgentsBlock` round-trip, `ExtractBlock` + edge cases (no markers, nested markers), `FileAction` skip vs + overwrite vs force. + +## Notes + +The intent is to make the diff for TP-018 *only* show the genuinely +new upgrader code, not a copy of helpers initter already had. After +this lands, `internal/initter/initter.go` should be ~150 lines smaller +and `internal/scaffold/scaffold.go` should hold the lifted helpers. + +Do not change `Options` field names or wire in any new behavior — +that is TP-018's job. If a helper resists clean extraction (say, +because it reads three fields off `initter.Options`), keep it in +initter for now and revisit during TP-018 implementation. Refactor +debt is fine; over-design is not. + +The `Action.Kind` string set stays as-is (`"mkdir"`, `"write-file"`, +`"merge-agents"`, `"skip"`). TP-018 may add `"remove"` and +`"refresh"` — that addition is in scope for TP-018, not here. + +The `templates/skills_lint_test.go` enumerates valid subcommands and +flags — it does not move; it stays in `templates/`. Only Go source +moves are in scope here. diff --git a/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md b/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md new file mode 100644 index 0000000..5602d90 --- /dev/null +++ b/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md @@ -0,0 +1,128 @@ +--- +title: Implement update-check (cached, gstack-style) + docops update-check subcommand +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0023] +depends_on: [] +--- + +## Goal + +Ship the update-check mechanism specified by ADR-0023: a small +internal package, a standalone `docops update-check` subcommand, and +integration into `docops upgrade` so users learn when their binary is +behind upstream before they sync templates. + +## Acceptance + +### `internal/updatecheck/` package + +- `Run(opts) (Result, error)` where `Result` is one of: + - `{Status: StatusUpToDate, Local: "0.1.2"}` + - `{Status: StatusUpgradeAvailable, Local: "0.1.1", Remote: "0.1.2"}` + - `{Status: StatusSkipped, Reason: "snoozed" | "disabled" | "dev-build" | "offline"}` +- `Opts` fields: + - `Local string` — caller-provided local version (from + `internal/version`). + - `RemoteURL string` — defaults to + `https://raw.githubusercontent.com/logicwind/docops/main/VERSION`. + - `StateDir string` — defaults to `$HOME/.docops`. Tests inject a + tempdir. + - `Force bool` — bypass cache (used by `--force`). + - `Timeout time.Duration` — defaults to 5s. + - `Now func() time.Time` — defaults to `time.Now`. For tests. +- Cache file `/last-update-check` with a single line: + - `UP_TO_DATE ` (TTL 6h) + - `UPGRADE_AVAILABLE ` (TTL 24h) +- Snooze file `/update-snoozed` with ` + `. Levels: 1=24h, 2=48h, 3+=7d. + `UpdateSnooze(remote string)` helper bumps the level. +- Skip rules (return `StatusSkipped` without network I/O): + - Local version starts with `dev` or contains `+dirty`. + - Env `DOCOPS_UPDATE_CHECK=off`. + - Active snooze for the matching remote version. +- Fail-quiet: any network error, timeout, or invalid response → + write `UP_TO_DATE ` to cache and return `StatusUpToDate` + with no error (so callers never see a network failure). +- Validation: remote response must match `^[0-9]+\.[0-9]+\.[0-9]+$` + after trimming whitespace. + +### `cmd/docops/cmd_update_check.go` + +- Subcommand `docops update-check` with flags: + - `--force` — bypass cache. + - `--snooze` — record a snooze for the current available remote. + No-op if `UP_TO_DATE`. + - `--json` — emit `{"status": "...", "local": "...", "remote": + "..."}`. +- Default output: + - `UP_TO_DATE 0.1.2` to stdout, exit 0. + - `UPGRADE_AVAILABLE 0.1.1 0.1.2` to stdout, exit 0. + - Skipped → no output, exit 0. +- Wired into `cmd/docops/main.go` dispatch and into the top-level + help text. `templates/skills_lint_test.go` learns the new + subcommand and its flags. + +### Integration into `docops upgrade` + +- `cmd_upgrade.go` calls `updatecheck.Run` after the plan is + computed but before the `Proceed?` prompt. +- If `StatusUpgradeAvailable`, print: + ``` + Warning: docops 0.1.1 is installed; 0.1.2 is available. + Run `brew upgrade docops` (or your package manager + equivalent) before `docops upgrade`, or you'll sync the + older templates. + ``` + Then prompt `Continue with 0.1.1 templates anyway? [y/N]`. `--yes` + skips the prompt (proceeds). On non-TTY stdin, the warning prints + and we proceed (so CI is not blocked). +- `UP_TO_DATE` and `StatusSkipped` print nothing extra. + +### `VERSION` file + +- A new `VERSION` file at the repo root containing the current + release (initially `0.1.2`). Goreleaser is updated (or a Makefile + pre-release target is added) to keep this in sync with tags. If + goreleaser config edit is non-trivial, ship the file and document + the manual bump step in `RELEASE.md` or the README "Releasing" + section. + +### Tests + +- `internal/updatecheck/updatecheck_test.go`: + - Cache hit (UP_TO_DATE within 6h) skips network entirely (use a + test HTTP server that fails the test if hit). + - Cache hit (UPGRADE_AVAILABLE within 24h) returns the cached + upgrade. + - Stale cache triggers re-fetch. + - Network error → `StatusUpToDate`, cache written. + - Invalid response (HTML, empty, malformed version) → treated as + network error. + - Dev build → `StatusSkipped`. + - `DOCOPS_UPDATE_CHECK=off` → `StatusSkipped`. + - Snooze file present, version matches, within window → + `StatusSkipped`. + - Snooze for an older remote does not suppress a newer remote. +- `cmd/docops/cmd_update_check_test.go`: subcommand exit codes, + output shapes (text + JSON), `--force` bypasses cache. +- `cmd/docops/cmd_upgrade_test.go` (added in TP-018) gains a case + for the stale-binary warning path with a fake updatecheck stub. + +## Notes + +`internal/updatecheck/` is a leaf package — it depends only on the +standard library (`net/http`, `os`, `time`, `errors`) and on +`internal/version` for the local version string. Keep it that way; +do not import `internal/initter`, `internal/upgrader`, or anything +template-related. + +`~/.docops/` is created lazily on first write. If `os.UserHomeDir()` +fails (CI without HOME set), the package returns `StatusSkipped` +with reason `"no-home-dir"` — no error to the caller. + +A future ADR may broaden the integration to `docops init` (warn +first-time users they are already a release behind). Out of scope +here — TP-021 only wires `update-check` into `upgrade` and the +standalone subcommand. From aa397d67f95bd6935436b4532e91c99d661c3ac2 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 01:23:07 +0530 Subject: [PATCH 04/11] TP-020: extract internal/scaffold/ from internal/initter/ Move the helpers TP-018's upgrader will need to share with init into a new internal/scaffold/ package: Action, the AGENTS.md block markers, MergeAgentsBlock/ExtractBlock, DirAction/FileAction, Execute, PrintPlan, and a LoadShippedSkills wrapper around templates.Skills(). initter.go shrinks from 429 to ~225 lines and re-imports from scaffold; the public Run/Options/Result surface is unchanged (Action stays available via a type alias). Tests cover the moved helpers directly. Co-Authored-By: Claude Sonnet 4.6 --- internal/initter/initter.go | 231 ++++----------------------- internal/initter/initter_test.go | 6 +- internal/scaffold/scaffold.go | 201 ++++++++++++++++++++++++ internal/scaffold/scaffold_test.go | 243 +++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 199 deletions(-) create mode 100644 internal/scaffold/scaffold.go create mode 100644 internal/scaffold/scaffold_test.go diff --git a/internal/initter/initter.go b/internal/initter/initter.go index 23af34c..ad02655 100644 --- a/internal/initter/initter.go +++ b/internal/initter/initter.go @@ -12,9 +12,9 @@ import ( "os" "path/filepath" "sort" - "strings" "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/scaffold" "github.com/logicwind/docops/internal/schema" "github.com/logicwind/docops/templates" ) @@ -25,33 +25,14 @@ type Options struct { Root string DryRun bool Force bool - NoSkills bool // skip scaffolding .claude/skills/docops/ and .cursor/commands/docops/ + NoSkills bool // skip scaffolding .claude/skills/docops/ and .cursor/commands/docops/ Out io.Writer // human-readable progress; defaults to os.Stdout Verbose bool } -// Action is a single filesystem change proposed by the planner. Init -// plans the full change set first, then executes it in one pass so -// --dry-run and the real write share the same code path. -type Action struct { - // Path is the absolute destination. - Path string - - // Rel is Path relative to Options.Root, used in human output. - Rel string - - // Kind is one of "mkdir", "write-file", "merge-agents", "skip". - Kind string - - // Reason explains why the action is a skip, write, or overwrite. - Reason string - - // Body is the bytes that would be written. Empty for mkdir / skip. - Body []byte - - // Mode is the permission for written files (0o755 for the hook, 0o644 otherwise). - Mode os.FileMode -} +// Action aliases scaffold.Action so existing initter consumers keep +// their imports unchanged after the scaffold extraction (TP-020). +type Action = scaffold.Action // Result is what Run returns after a plan execution. type Result struct { @@ -85,7 +66,7 @@ func Run(opts Options) (*Result, error) { } for i := range actions { - if err := execute(&actions[i]); err != nil { + if err := scaffold.Execute(&actions[i]); err != nil { return nil, fmt.Errorf("apply %s: %w", actions[i].Rel, err) } } @@ -97,8 +78,6 @@ func Run(opts Options) (*Result, error) { // to disk in here — it only reads existing files to decide whether each // proposed target would be a create, a merge, or a skip. func plan(opts Options) ([]Action, error) { - // If a docops.yaml already exists at the root, use it so that - // project-specific context_types propagate into the emitted schema. cfg := config.Default() if loaded, err := config.Load(filepath.Join(opts.Root, config.DefaultFilename)); err == nil { cfg = loaded @@ -113,7 +92,7 @@ func plan(opts Options) ([]Action, error) { cfg.Paths.Tasks, cfg.Paths.Schema, } { - actions = append(actions, dirAction(opts, rel)) + actions = append(actions, scaffold.DirAction(opts.Root, rel)) } // 2. docops.yaml at repo root. @@ -121,7 +100,7 @@ func plan(opts Options) ([]Action, error) { if err != nil { return nil, fmt.Errorf("read docops.yaml template: %w", err) } - actions = append(actions, fileAction(opts, "docops.yaml", yamlBody, 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, "docops.yaml", yamlBody, 0o644, opts.Force)) // 3. JSON Schema files. schemas, err := schema.JSONSchemas(schema.Config{ContextTypes: cfg.ContextTypes}) @@ -135,7 +114,7 @@ func plan(opts Options) ([]Action, error) { sort.Strings(schemaNames) for _, name := range schemaNames { rel := filepath.Join(cfg.Paths.Schema, name) - actions = append(actions, fileAction(opts, rel, schemas[name], 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, opts.Force)) } // 4. AGENTS.md — delimited-block merge if a user file exists. @@ -163,7 +142,7 @@ func plan(opts Options) ([]Action, error) { // 6. Agent skills — .claude/skills/docops/ and .cursor/commands/docops/. // Skipped entirely when --no-skills is set; existing files are not touched. if !opts.NoSkills { - skills, err := templates.Skills() + skills, err := scaffold.LoadShippedSkills() if err != nil { return nil, fmt.Errorf("read skills: %w", err) } @@ -173,10 +152,10 @@ func plan(opts Options) ([]Action, error) { } sort.Strings(skillNames) for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { - actions = append(actions, dirAction(opts, dir)) + actions = append(actions, scaffold.DirAction(opts.Root, dir)) for _, name := range skillNames { rel := filepath.Join(dir, name) - actions = append(actions, fileAction(opts, rel, skills[name], 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, rel, skills[name], 0o644, opts.Force)) } } } @@ -184,57 +163,6 @@ func plan(opts Options) ([]Action, error) { return actions, nil } -// dirAction builds a mkdir action that is a skip when the directory -// already exists. Keeps idempotent re-runs quiet. -func dirAction(opts Options, rel string) Action { - abs := filepath.Join(opts.Root, rel) - if info, err := os.Stat(abs); err == nil && info.IsDir() { - return Action{ - Path: abs, - Rel: rel, - Kind: "skip", - Reason: "directory exists", - Mode: 0o755, - } - } - return Action{ - Path: abs, - Rel: rel, - Kind: "mkdir", - Reason: "create directory", - Mode: 0o755, - } -} - -// fileAction builds a write-file action, deciding whether the target -// should be created, overwritten (--force on drift), or skipped. -func fileAction(opts Options, rel string, body []byte, mode os.FileMode) Action { - abs := filepath.Join(opts.Root, rel) - a := Action{Path: abs, Rel: rel, Kind: "write-file", Body: body, Mode: mode} - existing, err := os.ReadFile(abs) - if err != nil { - if os.IsNotExist(err) { - a.Reason = "create" - return a - } - // Permission/other error — propagate via execute(). - a.Reason = "create (read failed: " + err.Error() + ")" - return a - } - if bytes.Equal(existing, body) { - a.Kind = "skip" - a.Reason = "already up to date" - return a - } - if opts.Force { - a.Reason = "overwrite drifted content (--force)" - return a - } - a.Kind = "skip" - a.Reason = "exists and differs — rerun with --force to overwrite" - return a -} - // planAgents decides how to render AGENTS.md. If the file is absent we // write the template verbatim. If it exists with a block, we replace // just the block. If it exists without a block, we append the block. @@ -250,26 +178,26 @@ func planAgents(opts Options, tmpl []byte) (Action, error) { return Action{ Path: abs, Rel: rel, - Kind: "write-file", + Kind: scaffold.KindWriteFile, Body: tmpl, Mode: 0o644, Reason: "create", }, nil } - merged, changed, reason := mergeAgentsBlock(existing, tmpl) + merged, changed, reason := scaffold.MergeAgentsBlock(existing, tmpl) if !changed { return Action{ Path: abs, Rel: rel, - Kind: "skip", + Kind: scaffold.KindSkip, Reason: reason, }, nil } return Action{ Path: abs, Rel: rel, - Kind: "merge-agents", + Kind: scaffold.KindMergeAgents, Body: merged, Mode: 0o644, Reason: reason, @@ -285,13 +213,13 @@ func planHook(opts Options, body []byte) (Action, error) { return Action{ Path: filepath.Join(gitDir, "hooks", "pre-commit"), Rel: ".git/hooks/pre-commit", - Kind: "skip", + Kind: scaffold.KindSkip, Reason: "no .git directory — run `git init` first or install the hook manually from templates/hooks/pre-commit", }, nil } rel := ".git/hooks/pre-commit" abs := filepath.Join(opts.Root, rel) - a := Action{Path: abs, Rel: rel, Kind: "write-file", Body: body, Mode: 0o755} + a := Action{Path: abs, Rel: rel, Kind: scaffold.KindWriteFile, Body: body, Mode: 0o755} existing, err := os.ReadFile(abs) if err != nil { if os.IsNotExist(err) { @@ -301,7 +229,7 @@ func planHook(opts Options, body []byte) (Action, error) { return Action{}, err } if bytes.Equal(existing, body) { - a.Kind = "skip" + a.Kind = scaffold.KindSkip a.Reason = "already up to date" return a, nil } @@ -309,121 +237,30 @@ func planHook(opts Options, body []byte) (Action, error) { a.Reason = "overwrite drifted hook (--force)" return a, nil } - a.Kind = "skip" + a.Kind = scaffold.KindSkip a.Reason = "existing pre-commit hook differs — rerun with --force to overwrite" return a, nil } -// blockStart and blockEnd match the delimiters defined in docops.yaml -// agents_md block_start / block_end. Keeping them as constants here -// mirrors the template default; a user that changes delimiters in -// docops.yaml will still get a correct write from init (no block exists -// yet) and can update via a future `docops refresh-agents-md` command. -const ( - blockStart = "" - blockEnd = "" -) - -// mergeAgentsBlock replaces the docops:start/end block inside existing -// content with the block extracted from the template. If no block is -// present in existing, the template body is appended. Returns the merged -// bytes, a changed flag (false = identical to existing), and a reason -// string for human output. -func mergeAgentsBlock(existing, tmpl []byte) ([]byte, bool, string) { - tmplBlock := extractBlock(tmpl) - if tmplBlock == "" { - // Template is malformed — return existing unchanged rather than - // scribbling on the user's file. - return existing, false, "template missing docops block; skipping merge" - } - - ex := string(existing) - startIdx := strings.Index(ex, blockStart) - endIdx := strings.Index(ex, blockEnd) - if startIdx >= 0 && endIdx > startIdx { - endIdx += len(blockEnd) - replacement := blockStart + tmplBlock + blockEnd - merged := ex[:startIdx] + replacement + ex[endIdx:] - if merged == ex { - return existing, false, "docops block already up to date" - } - return []byte(merged), true, "refresh docops block" - } - - // No block yet — append the template block (with delimiters) to the end. - appended := ex - if !strings.HasSuffix(appended, "\n") { - appended += "\n" - } - appended += "\n" + blockStart + tmplBlock + blockEnd + "\n" - return []byte(appended), true, "append docops block to existing AGENTS.md" -} - -// extractBlock returns the content between blockStart and blockEnd in -// tmpl. Returns "" if either marker is missing. Leading/trailing -// newlines are preserved so the rendered block is visually identical -// to the template. -func extractBlock(tmpl []byte) string { - s := string(tmpl) - start := strings.Index(s, blockStart) - if start < 0 { - return "" - } - start += len(blockStart) - end := strings.Index(s, blockEnd) - if end < 0 || end < start { - return "" - } - return s[start:end] -} - -// execute applies one planned action to the filesystem. -func execute(a *Action) error { - switch a.Kind { - case "skip": - return nil - case "mkdir": - return os.MkdirAll(a.Path, 0o755) - case "write-file", "merge-agents": - if err := os.MkdirAll(filepath.Dir(a.Path), 0o755); err != nil { - return err - } - if err := os.WriteFile(a.Path, a.Body, a.Mode); err != nil { - return err - } - return nil - } - return fmt.Errorf("unknown action kind %q", a.Kind) -} - -// printPlan writes a human-readable summary of the action set. For -// dry-run we use the "would" phrasing; for real runs we use past tense. +// printPlan adds the init-specific "next steps" footer to scaffold's +// generic plan summary. func printPlan(w io.Writer, actions []Action, dry bool) { - var changed, skipped int - for _, a := range actions { - if a.Kind == "skip" { - skipped++ - } else { - changed++ - } - } - verb := "applied" + scaffold.PrintPlan(w, actions, dry, "docops init") if dry { - verb = "would apply" + return } - fmt.Fprintf(w, "docops init: %s %d change(s), skipped %d\n", verb, changed, skipped) + var changed int for _, a := range actions { - tag := a.Kind - if dry && a.Kind != "skip" { - tag = "+ " + a.Kind + if a.Kind != scaffold.KindSkip { + changed++ } - fmt.Fprintf(w, " %-13s %-34s %s\n", tag, a.Rel, a.Reason) } - if !dry && changed > 0 { - fmt.Fprintln(w, "") - fmt.Fprintln(w, "next steps:") - fmt.Fprintln(w, " docops validate # confirm the scaffolded docs parse") - fmt.Fprintln(w, " docops new ctx \"…\" --type brief") - fmt.Fprintln(w, " docops new adr \"…\"") + if changed == 0 { + return } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "next steps:") + fmt.Fprintln(w, " docops validate # confirm the scaffolded docs parse") + fmt.Fprintln(w, " docops new ctx \"…\" --type brief") + fmt.Fprintln(w, " docops new adr \"…\"") } diff --git a/internal/initter/initter_test.go b/internal/initter/initter_test.go index 1d340f6..72d7b88 100644 --- a/internal/initter/initter_test.go +++ b/internal/initter/initter_test.go @@ -8,6 +8,8 @@ import ( "runtime" "strings" "testing" + + "github.com/logicwind/docops/internal/scaffold" ) // withGit creates a fake .git directory so planHook does not short-circuit. @@ -173,7 +175,7 @@ func TestRun_AgentsMerge_PreservesUserContent(t *testing.T) { if !strings.Contains(s, "Own content that DocOps must not touch.") { t.Errorf("user content missing after merge: %s", s) } - if !strings.Contains(s, blockStart) || !strings.Contains(s, blockEnd) { + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { t.Errorf("docops block delimiters missing after merge: %s", s) } } @@ -182,7 +184,7 @@ func TestRun_AgentsBlockRefresh_ReplacesBlockOnly(t *testing.T) { root := t.TempDir() withGit(t, root) - existing := "# Header\n\n" + blockStart + "\nstale block content\n" + blockEnd + "\n\n## Keep me\n\nuser footer\n" + existing := "# Header\n\n" + scaffold.BlockStart + "\nstale block content\n" + scaffold.BlockEnd + "\n\n## Keep me\n\nuser footer\n" if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(existing), 0o644); err != nil { t.Fatalf("write existing: %v", err) } diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go new file mode 100644 index 0000000..8ebac5f --- /dev/null +++ b/internal/scaffold/scaffold.go @@ -0,0 +1,201 @@ +// Package scaffold contains filesystem-action helpers shared by the +// scaffolding commands (`docops init`, `docops upgrade`). It is a pure +// utility layer: planning a write, deciding whether a file is up to +// date, merging the AGENTS.md docops block, and applying actions to +// disk. Higher-level packages (initter, upgrader) compose these +// primitives into command-specific behavior. +package scaffold + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/logicwind/docops/templates" +) + +// Action kinds. Kept as string constants (not a typed enum) so JSON +// output and human-readable diffs share one vocabulary. +const ( + KindMkdir = "mkdir" + KindWriteFile = "write-file" + KindMergeAgents = "merge-agents" + KindSkip = "skip" +) + +// AGENTS.md docops block delimiters. Mirrors the defaults in the +// shipped docops.yaml template so init and upgrade agree on what the +// "managed region" looks like. +const ( + BlockStart = "" + BlockEnd = "" +) + +// Action is a single filesystem change proposed by a planner. Planners +// build the full action set first, then execute it in one pass so +// dry-run and the real write share the same code path. +type Action struct { + Path string // absolute destination + Rel string // Path relative to the project root, used in human output + Kind string // one of the Kind* constants + Reason string // why the action is a skip, write, or overwrite + Body []byte // bytes to write (empty for mkdir / skip) + Mode os.FileMode // 0o755 for the pre-commit hook, 0o644 otherwise +} + +// DirAction builds a mkdir action that becomes a skip when the +// directory already exists. Keeps idempotent re-runs quiet. +func DirAction(rootAbs, rel string) Action { + abs := filepath.Join(rootAbs, rel) + if info, err := os.Stat(abs); err == nil && info.IsDir() { + return Action{ + Path: abs, + Rel: rel, + Kind: KindSkip, + Reason: "directory exists", + Mode: 0o755, + } + } + return Action{ + Path: abs, + Rel: rel, + Kind: KindMkdir, + Reason: "create directory", + Mode: 0o755, + } +} + +// FileAction builds a write-file action, deciding whether the target +// should be created, overwritten (force=true on drift), or skipped. +// A read error other than ENOENT is folded into the action's Reason +// and surfaces during Execute. +func FileAction(rootAbs, rel string, body []byte, mode os.FileMode, force bool) Action { + abs := filepath.Join(rootAbs, rel) + a := Action{Path: abs, Rel: rel, Kind: KindWriteFile, Body: body, Mode: mode} + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + a.Reason = "create" + return a + } + a.Reason = "create (read failed: " + err.Error() + ")" + return a + } + if bytes.Equal(existing, body) { + a.Kind = KindSkip + a.Reason = "already up to date" + return a + } + if force { + a.Reason = "overwrite drifted content (--force)" + return a + } + a.Kind = KindSkip + a.Reason = "exists and differs — rerun with --force to overwrite" + return a +} + +// MergeAgentsBlock replaces the docops:start/end block inside existing +// content with the block extracted from tmpl. If no block is present +// in existing, the template block is appended. Returns the merged +// bytes, a changed flag (false = identical to existing), and a reason +// string for human output. +func MergeAgentsBlock(existing, tmpl []byte) ([]byte, bool, string) { + tmplBlock := ExtractBlock(tmpl) + if tmplBlock == "" { + return existing, false, "template missing docops block; skipping merge" + } + + ex := string(existing) + startIdx := strings.Index(ex, BlockStart) + endIdx := strings.Index(ex, BlockEnd) + if startIdx >= 0 && endIdx > startIdx { + endIdx += len(BlockEnd) + replacement := BlockStart + tmplBlock + BlockEnd + merged := ex[:startIdx] + replacement + ex[endIdx:] + if merged == ex { + return existing, false, "docops block already up to date" + } + return []byte(merged), true, "refresh docops block" + } + + appended := ex + if !strings.HasSuffix(appended, "\n") { + appended += "\n" + } + appended += "\n" + BlockStart + tmplBlock + BlockEnd + "\n" + return []byte(appended), true, "append docops block to existing AGENTS.md" +} + +// ExtractBlock returns the content between BlockStart and BlockEnd in +// tmpl. Returns "" if either marker is missing. Leading/trailing +// newlines are preserved so the rendered block matches the template +// byte-for-byte. +func ExtractBlock(tmpl []byte) string { + s := string(tmpl) + start := strings.Index(s, BlockStart) + if start < 0 { + return "" + } + start += len(BlockStart) + end := strings.Index(s, BlockEnd) + if end < 0 || end < start { + return "" + } + return s[start:end] +} + +// Execute applies one planned action to the filesystem. Skips are +// no-ops; mkdir creates with 0o755; writes ensure parent dirs exist +// and use the action's Mode. +func Execute(a *Action) error { + switch a.Kind { + case KindSkip: + return nil + case KindMkdir: + return os.MkdirAll(a.Path, 0o755) + case KindWriteFile, KindMergeAgents: + if err := os.MkdirAll(filepath.Dir(a.Path), 0o755); err != nil { + return err + } + return os.WriteFile(a.Path, a.Body, a.Mode) + } + return fmt.Errorf("unknown action kind %q", a.Kind) +} + +// PrintPlan writes a human-readable summary of an action set. label +// prefixes the summary line ("docops init", "docops upgrade", …). +// dry switches verbs from past tense to "would apply". Callers may +// append command-specific footers (next-steps, warnings) afterwards. +func PrintPlan(w io.Writer, actions []Action, dry bool, label string) { + var changed, skipped int + for _, a := range actions { + if a.Kind == KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "%s: %s %d change(s), skipped %d\n", label, verb, changed, skipped) + for _, a := range actions { + tag := a.Kind + if dry && a.Kind != KindSkip { + tag = "+ " + a.Kind + } + fmt.Fprintf(w, " %-13s %-34s %s\n", tag, a.Rel, a.Reason) + } +} + +// LoadShippedSkills returns the embedded skill files keyed by +// basename (e.g. "init.md" → bytes). Thin wrapper around +// templates.Skills() so initter and upgrader share one entry point. +func LoadShippedSkills() (map[string][]byte, error) { + return templates.Skills() +} diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..3b47340 --- /dev/null +++ b/internal/scaffold/scaffold_test.go @@ -0,0 +1,243 @@ +package scaffold + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractBlock_HappyPath(t *testing.T) { + tmpl := []byte("preamble\n" + BlockStart + "\nbody\n" + BlockEnd + "\ntrailer\n") + got := ExtractBlock(tmpl) + want := "\nbody\n" + if got != want { + t.Errorf("ExtractBlock = %q; want %q", got, want) + } +} + +func TestExtractBlock_MissingMarkers(t *testing.T) { + cases := map[string][]byte{ + "no markers": []byte("just plain text\n"), + "only start": []byte("hello\n" + BlockStart + "\nrest\n"), + "only end": []byte("hello\n" + BlockEnd + "\nrest\n"), + "end before start": []byte(BlockEnd + "\n" + BlockStart + "\n"), + } + for name, tmpl := range cases { + t.Run(name, func(t *testing.T) { + if got := ExtractBlock(tmpl); got != "" { + t.Errorf("ExtractBlock(%q) = %q; want empty", name, got) + } + }) + } +} + +func TestMergeAgentsBlock_AppendsWhenAbsent(t *testing.T) { + existing := []byte("# Project\n\nHand-written notes.\n") + tmpl := []byte(BlockStart + "\nshipped block body\n" + BlockEnd + "\n") + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if !changed { + t.Fatalf("expected changed=true; reason=%q", reason) + } + s := string(merged) + if !strings.Contains(s, "Hand-written notes.") { + t.Errorf("user content lost: %s", s) + } + if !strings.Contains(s, BlockStart) || !strings.Contains(s, BlockEnd) { + t.Errorf("block delimiters missing: %s", s) + } + if !strings.Contains(s, "shipped block body") { + t.Errorf("template body missing: %s", s) + } +} + +func TestMergeAgentsBlock_ReplacesInPlace(t *testing.T) { + existing := []byte("# Header\n\n" + BlockStart + "\nstale\n" + BlockEnd + "\n\nuser footer\n") + tmpl := []byte(BlockStart + "\nfresh\n" + BlockEnd + "\n") + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if !changed { + t.Fatalf("expected changed=true; reason=%q", reason) + } + s := string(merged) + if strings.Contains(s, "stale") { + t.Errorf("stale block content survived: %s", s) + } + if !strings.Contains(s, "fresh") { + t.Errorf("fresh block content missing: %s", s) + } + if !strings.Contains(s, "user footer") { + t.Errorf("user content outside block was lost: %s", s) + } +} + +func TestMergeAgentsBlock_NoOpWhenIdentical(t *testing.T) { + body := BlockStart + "\nshared body\n" + BlockEnd + "\n" + existing := []byte("# Header\n\n" + body + "\nfooter\n") + tmpl := []byte(body) + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if changed { + t.Fatalf("expected changed=false when block matches; reason=%q", reason) + } + if !bytes.Equal(merged, existing) { + t.Errorf("merged should equal existing on no-op") + } +} + +func TestMergeAgentsBlock_RefusesMalformedTemplate(t *testing.T) { + existing := []byte("# Header\n") + tmpl := []byte("template with no markers at all\n") + + _, changed, reason := MergeAgentsBlock(existing, tmpl) + if changed { + t.Errorf("expected changed=false on malformed template") + } + if !strings.Contains(reason, "template missing") { + t.Errorf("expected reason about missing template block, got %q", reason) + } +} + +func TestFileAction_CreateWhenAbsent(t *testing.T) { + root := t.TempDir() + a := FileAction(root, "new.txt", []byte("hello"), 0o644, false) + if a.Kind != KindWriteFile { + t.Errorf("Kind = %q; want %q", a.Kind, KindWriteFile) + } + if a.Reason != "create" { + t.Errorf("Reason = %q; want %q", a.Reason, "create") + } +} + +func TestFileAction_SkipWhenIdentical(t *testing.T) { + root := t.TempDir() + body := []byte("same body\n") + if err := os.WriteFile(filepath.Join(root, "same.txt"), body, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "same.txt", body, 0o644, false) + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q", a.Kind, KindSkip) + } + if !strings.Contains(a.Reason, "up to date") { + t.Errorf("Reason = %q; want 'up to date' phrasing", a.Reason) + } +} + +func TestFileAction_SkipWhenDriftedNoForce(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "drift.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "drift.txt", []byte("new"), 0o644, false) + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q (skip on drift without --force)", a.Kind, KindSkip) + } + if !strings.Contains(a.Reason, "--force") { + t.Errorf("Reason should hint at --force, got %q", a.Reason) + } +} + +func TestFileAction_OverwriteWhenDriftedWithForce(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "drift.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "drift.txt", []byte("new"), 0o644, true) + if a.Kind != KindWriteFile { + t.Errorf("Kind = %q; want %q (overwrite with --force)", a.Kind, KindWriteFile) + } + if !strings.Contains(a.Reason, "overwrite") { + t.Errorf("Reason should describe overwrite, got %q", a.Reason) + } +} + +func TestDirAction_CreateWhenAbsent(t *testing.T) { + root := t.TempDir() + a := DirAction(root, "subdir/nested") + if a.Kind != KindMkdir { + t.Errorf("Kind = %q; want %q", a.Kind, KindMkdir) + } +} + +func TestDirAction_SkipWhenExists(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "exists"), 0o755); err != nil { + t.Fatalf("seed: %v", err) + } + a := DirAction(root, "exists") + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q", a.Kind, KindSkip) + } +} + +func TestExecute_WriteAndMkdir(t *testing.T) { + root := t.TempDir() + + mk := DirAction(root, "made/sub") + if err := Execute(&mk); err != nil { + t.Fatalf("execute mkdir: %v", err) + } + if info, err := os.Stat(filepath.Join(root, "made", "sub")); err != nil || !info.IsDir() { + t.Fatalf("mkdir did not create dir: %v", err) + } + + wr := FileAction(root, "made/file.txt", []byte("body"), 0o644, false) + if err := Execute(&wr); err != nil { + t.Fatalf("execute write: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "made", "file.txt")) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != "body" { + t.Errorf("file body = %q; want %q", got, "body") + } +} + +func TestExecute_SkipIsNoOp(t *testing.T) { + a := Action{Kind: KindSkip, Path: "/nonexistent/should/not/be/touched"} + if err := Execute(&a); err != nil { + t.Errorf("skip should be no-op, got error: %v", err) + } +} + +func TestPrintPlan_LabelAndCounts(t *testing.T) { + actions := []Action{ + {Rel: "a.txt", Kind: KindWriteFile, Reason: "create"}, + {Rel: "b.txt", Kind: KindSkip, Reason: "already up to date"}, + {Rel: "c/", Kind: KindMkdir, Reason: "create directory"}, + } + var buf bytes.Buffer + PrintPlan(&buf, actions, true, "docops upgrade") + out := buf.String() + if !strings.Contains(out, "docops upgrade: would apply 2 change(s), skipped 1") { + t.Errorf("summary line missing/wrong: %s", out) + } + if !strings.Contains(out, "+ write-file") { + t.Errorf("dry-run sigil missing for non-skip action: %s", out) + } + if !strings.Contains(out, "skip") { + t.Errorf("skip line missing: %s", out) + } +} + +func TestLoadShippedSkills_NonEmpty(t *testing.T) { + skills, err := LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + if len(skills) == 0 { + t.Fatal("expected at least one shipped skill") + } + for name, body := range skills { + if !strings.HasSuffix(name, ".md") { + t.Errorf("skill name %q does not end in .md", name) + } + if len(body) == 0 { + t.Errorf("skill %q has empty body", name) + } + } +} From d4ff2d1144fefb0683b0e9d386f1063cf3d20e80 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 01:30:16 +0530 Subject: [PATCH 05/11] =?UTF-8?q?TP-021:=20docops=20update-check=20?= =?UTF-8?q?=E2=80=94=20cached,=20lazy,=20opt-out=20version=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/updatecheck/ package implements the gstack-shaped pattern: cache last-update-check under ~/.docops/ with split TTLs (6h up-to-date, 24h upgrade-available), fetch from raw.githubusercontent.com/.../VERSION with a 5s timeout, fail quiet on every transient error so docops never blocks a commit hook. Snooze file with escalating windows (24h/48h/7d) keeps reminders bounded. Skips entirely on dev builds, +dirty trees, and DOCOPS_UPDATE_CHECK=off. Standalone subcommand `docops update-check` prints UP_TO_DATE / UPGRADE_AVAILABLE on one line (or nothing when skipped) so users and shell hooks can script around it. --force bypasses cache, --snooze records a snooze for the current available remote, --json emits structured output. VERSION file at the repo root (initially 0.1.1) is the upstream source of truth; release-time bumps land alongside the git tag. The upgrade integration (warn before in-place sync when binary is behind) ships with TP-018. Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 1 + cmd/docops/cmd_update_check.go | 114 +++++++ cmd/docops/cmd_update_check_test.go | 149 +++++++++ cmd/docops/main.go | 3 + internal/updatecheck/updatecheck.go | 322 +++++++++++++++++++ internal/updatecheck/updatecheck_test.go | 383 +++++++++++++++++++++++ templates/skills_lint_test.go | 17 +- 7 files changed, 981 insertions(+), 8 deletions(-) create mode 100644 VERSION create mode 100644 cmd/docops/cmd_update_check.go create mode 100644 cmd/docops/cmd_update_check_test.go create mode 100644 internal/updatecheck/updatecheck.go create mode 100644 internal/updatecheck/updatecheck_test.go diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/cmd/docops/cmd_update_check.go b/cmd/docops/cmd_update_check.go new file mode 100644 index 0000000..c89d82e --- /dev/null +++ b/cmd/docops/cmd_update_check.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/logicwind/docops/internal/updatecheck" + "github.com/logicwind/docops/internal/version" +) + +// cmdUpdateCheck implements `docops update-check`. Prints exactly one +// line (or zero, if the check was skipped) and always exits 0 — the +// subcommand is a probe, not a guard. +// +// Output: +// +// UP_TO_DATE 0.1.2 +// UPGRADE_AVAILABLE 0.1.1 0.1.2 +// (nothing) — skipped/snoozed/disabled +// +// Flags: +// +// --force bypass cache (and snooze) for one fresh probe +// --snooze record a snooze for the current available remote +// --json emit a structured object instead of the line above +func cmdUpdateCheck(args []string) int { + return runUpdateCheck(args, os.Stdout, os.Stderr, version.Version, "") +} + +// runUpdateCheck is the testable core. local injects the version +// (otherwise read from the version package); stateDirOverride lets +// tests redirect the state dir without touching $HOME. +func runUpdateCheck(args []string, stdout, stderr io.Writer, local, stateDirOverride string) int { + fs := flag.NewFlagSet("update-check", flag.ContinueOnError) + fs.SetOutput(stderr) + force := fs.Bool("force", false, "bypass cache and snooze for one fresh probe") + snooze := fs.Bool("snooze", false, "snooze the currently available upgrade") + asJSON := fs.Bool("json", false, "emit a JSON object instead of the line format") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: docops update-check [--force] [--snooze] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + stateDir := stateDirOverride + if stateDir == "" { + stateDir = updatecheck.DefaultStateDir() + } + + res, err := updatecheck.Run(updatecheck.Opts{ + Local: local, + RemoteURL: os.Getenv("DOCOPS_REMOTE_URL"), + StateDir: stateDir, + Force: *force, + }) + if err != nil { + fmt.Fprintf(stderr, "docops update-check: %v\n", err) + return 2 + } + + if *snooze && res.Status == updatecheck.StatusUpgradeAvailable { + if err := updatecheck.Snooze(stateDir, res.Remote, time.Now()); err != nil { + fmt.Fprintf(stderr, "docops update-check: snooze: %v\n", err) + return 2 + } + // After snoozing, future cached reads will suppress; for this + // invocation, fall through to print the available upgrade so + // the user sees what they just snoozed. + } + + if *asJSON { + emitUpdateCheckJSON(stdout, res) + return 0 + } + emitUpdateCheckLine(stdout, res) + return 0 +} + +func emitUpdateCheckLine(w io.Writer, res updatecheck.Result) { + switch res.Status { + case updatecheck.StatusUpToDate: + fmt.Fprintf(w, "UP_TO_DATE %s\n", res.Local) + case updatecheck.StatusUpgradeAvailable: + fmt.Fprintf(w, "UPGRADE_AVAILABLE %s %s\n", res.Local, res.Remote) + case updatecheck.StatusSkipped: + // Intentional: scripts treat silence as "no signal". + } +} + +func emitUpdateCheckJSON(w io.Writer, res updatecheck.Result) { + payload := map[string]string{ + "status": res.Status.String(), + "local": res.Local, + } + if res.Remote != "" { + payload["remote"] = res.Remote + } + if res.Reason != "" { + payload["reason"] = res.Reason + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(payload) +} diff --git a/cmd/docops/cmd_update_check_test.go b/cmd/docops/cmd_update_check_test.go new file mode 100644 index 0000000..1ba64d2 --- /dev/null +++ b/cmd/docops/cmd_update_check_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCmdUpdateCheck_UpToDateLine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.1\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UP_TO_DATE 0.1.1" { + t.Errorf("stdout = %q; want %q", got, "UP_TO_DATE 0.1.1") + } +} + +func TestCmdUpdateCheck_UpgradeAvailableLine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UPGRADE_AVAILABLE 0.1.1 0.1.2" { + t.Errorf("stdout = %q; want %q", got, "UPGRADE_AVAILABLE 0.1.1 0.1.2") + } +} + +func TestCmdUpdateCheck_DisabledIsSilent(t *testing.T) { + t.Setenv("DOCOPS_REMOTE_URL", "http://127.0.0.1:1/never-hit") + t.Setenv("DOCOPS_UPDATE_CHECK", "off") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q; want empty when disabled", stdout.String()) + } +} + +func TestCmdUpdateCheck_DevBuildIsSilent(t *testing.T) { + t.Setenv("DOCOPS_REMOTE_URL", "http://127.0.0.1:1/never-hit") + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "dev", state) + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q; want empty for dev build", stdout.String()) + } +} + +func TestCmdUpdateCheck_JSONShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--json"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + var payload map[string]string + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nbody=%s", err, stdout.String()) + } + if payload["status"] != "upgrade-available" || payload["local"] != "0.1.1" || payload["remote"] != "0.1.2" { + t.Errorf("payload = %v; missing/wrong fields", payload) + } +} + +func TestCmdUpdateCheck_ForceBypassesCache(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, "last-update-check"), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--force"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UPGRADE_AVAILABLE 0.1.1 0.1.2" { + t.Errorf("stdout = %q; want fresh upgrade line", got) + } +} + +func TestCmdUpdateCheck_SnoozeWritesFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--snooze"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + body, err := os.ReadFile(filepath.Join(state, "update-snoozed")) + if err != nil { + t.Fatalf("snooze file missing: %v", err) + } + if !strings.HasPrefix(string(body), "0.1.2 1 ") { + t.Errorf("snooze body = %q; want start with '0.1.2 1 '", body) + } +} diff --git a/cmd/docops/main.go b/cmd/docops/main.go index 62fc697..c192118 100644 --- a/cmd/docops/main.go +++ b/cmd/docops/main.go @@ -45,6 +45,8 @@ func main() { os.Exit(cmdNext(args[1:])) case "search": os.Exit(cmdSearch(args[1:])) + case "update-check": + os.Exit(cmdUpdateCheck(args[1:])) default: fmt.Fprintf(os.Stderr, "docops: unknown command %q\n\n", args[0]) topLevelUsage(os.Stderr) @@ -71,6 +73,7 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, " graph typed edge graph from a starting doc") fmt.Fprintln(w, " next recommend the next task to work on") fmt.Fprintln(w, " search substring/regex search over title, tags, and body") + fmt.Fprintln(w, " update-check check for a newer docops release (cached probe)") fmt.Fprintln(w, " version print the build version") fmt.Fprintln(w, "") fmt.Fprintln(w, "see `docops --help` for per-command flags.") diff --git a/internal/updatecheck/updatecheck.go b/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..f985cfa --- /dev/null +++ b/internal/updatecheck/updatecheck.go @@ -0,0 +1,322 @@ +// Package updatecheck implements a cached, lazy probe of the upstream +// docops VERSION file. It mirrors the gstack pattern: hit the network +// at most once per TTL window, fail quiet on any error, and surface +// `UPGRADE_AVAILABLE` to callers (the standalone `docops update-check` +// subcommand and the `docops upgrade` pre-flight) so users learn when +// their binary is behind upstream. +// +// The package has no project-state dependencies — it talks to the +// filesystem (cache + snooze files under ~/.docops/) and to the +// network (one HTTP GET) and that is all. +package updatecheck + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// DefaultRemoteURL is the canonical upstream version source. Override +// via Opts.RemoteURL or the DOCOPS_REMOTE_URL env var (CLI wires it). +const DefaultRemoteURL = "https://raw.githubusercontent.com/logicwind/docops/main/VERSION" + +// CacheFilename and SnoozeFilename live inside the per-user state dir. +const ( + CacheFilename = "last-update-check" + SnoozeFilename = "update-snoozed" +) + +// TTLs follow gstack's empirically chosen split: re-check up-to-date +// state often enough to surface fresh releases within a working day, +// but keep the upgrade-available reminder live for longer so users see +// it the next morning. +const ( + UpToDateTTL = 6 * time.Hour + UpgradeAvailableTTL = 24 * time.Hour + DefaultTimeout = 5 * time.Second +) + +// Status enumerates the three terminal states of a single Run call. +type Status int + +const ( + StatusUpToDate Status = iota + StatusUpgradeAvailable + StatusSkipped +) + +// String renders Status for human/JSON output. +func (s Status) String() string { + switch s { + case StatusUpToDate: + return "up-to-date" + case StatusUpgradeAvailable: + return "upgrade-available" + case StatusSkipped: + return "skipped" + } + return "unknown" +} + +// Result is what Run returns. Remote is empty unless Status is +// StatusUpgradeAvailable. Reason is populated for StatusSkipped. +type Result struct { + Status Status + Local string + Remote string + Reason string +} + +// Opts configures a single update check. Zero-value safe: missing +// fields fall back to sensible defaults during Run. +type Opts struct { + Local string // local version (caller-provided) + RemoteURL string // defaults to DefaultRemoteURL + StateDir string // defaults to $HOME/.docops + Force bool // bypass cache (used by --force) + Timeout time.Duration // defaults to DefaultTimeout + Now func() time.Time // defaults to time.Now (test seam) + HTTPClient *http.Client // defaults to a client with Timeout + Env func(string) string // defaults to os.Getenv (test seam) +} + +// versionRE validates remote and local version strings. Anything that +// does not match is treated as a network error. +var versionRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +// devVersionMarkers identify a non-release build that should not be +// compared against upstream. +var devVersionMarkers = []string{"dev", "+dirty", "(devel)"} + +// Run executes one update check. Errors are returned only for +// programming mistakes (missing Local). All transient failures +// (network errors, missing home dir, malformed cache) collapse into +// StatusSkipped or StatusUpToDate so callers never have to handle +// transient noise. +func Run(opts Opts) (Result, error) { + if opts.Local == "" { + return Result{}, errors.New("updatecheck: Local must be set") + } + applyDefaults(&opts) + + // Hard skips — never touch network or cache. + if isDevBuild(opts.Local) { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "dev-build"}, nil + } + if strings.EqualFold(opts.Env("DOCOPS_UPDATE_CHECK"), "off") { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "disabled"}, nil + } + if opts.StateDir == "" { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "no-home-dir"}, nil + } + + // Cache fast-path. + if !opts.Force { + if cached, ok := readCache(opts.StateDir, opts.Local, opts.Now()); ok { + if cached.Status == StatusUpgradeAvailable && isSnoozed(opts.StateDir, cached.Remote, opts.Now()) { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "snoozed"}, nil + } + return cached, nil + } + } + + // Slow path: fetch remote. + remote := fetchRemote(opts.HTTPClient, opts.RemoteURL) + if remote == "" || !versionRE.MatchString(remote) { + // Treat any failure as up-to-date so we don't loop on a flaky + // upstream. Cache it briefly so the next call doesn't re-hit + // the network either. + _ = writeCache(opts.StateDir, fmt.Sprintf("UP_TO_DATE %s", opts.Local)) + return Result{Status: StatusUpToDate, Local: opts.Local, Reason: "offline"}, nil + } + + if remote == opts.Local { + _ = writeCache(opts.StateDir, fmt.Sprintf("UP_TO_DATE %s", opts.Local)) + return Result{Status: StatusUpToDate, Local: opts.Local}, nil + } + + _ = writeCache(opts.StateDir, fmt.Sprintf("UPGRADE_AVAILABLE %s %s", opts.Local, remote)) + if isSnoozed(opts.StateDir, remote, opts.Now()) { + return Result{Status: StatusSkipped, Local: opts.Local, Remote: remote, Reason: "snoozed"}, nil + } + return Result{Status: StatusUpgradeAvailable, Local: opts.Local, Remote: remote}, nil +} + +// Snooze records a snooze for the given remote version. Each +// invocation bumps the level (1 → 2 → 3+), which extends the +// quiet-window. A new remote version invalidates the previous snooze +// implicitly (isSnoozed checks the version). +func Snooze(stateDir, remote string, now time.Time) error { + if stateDir == "" { + return errors.New("updatecheck: stateDir required to snooze") + } + level := 1 + if existing, err := readSnoozeFile(stateDir); err == nil && existing.version == remote { + level = existing.level + 1 + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + body := fmt.Sprintf("%s %d %d\n", remote, level, now.Unix()) + return os.WriteFile(filepath.Join(stateDir, SnoozeFilename), []byte(body), 0o644) +} + +// DefaultStateDir returns the per-user state directory ("$HOME/.docops"). +// Returns "" when $HOME is unavailable; callers treat that as +// StatusSkipped. +func DefaultStateDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".docops") +} + +// ───────────────────────────── internals ───────────────────────────── + +func applyDefaults(o *Opts) { + if o.RemoteURL == "" { + o.RemoteURL = DefaultRemoteURL + } + if o.StateDir == "" { + o.StateDir = DefaultStateDir() + } + if o.Timeout == 0 { + o.Timeout = DefaultTimeout + } + if o.Now == nil { + o.Now = time.Now + } + if o.Env == nil { + o.Env = os.Getenv + } + if o.HTTPClient == nil { + o.HTTPClient = &http.Client{Timeout: o.Timeout} + } +} + +func isDevBuild(v string) bool { + for _, m := range devVersionMarkers { + if strings.Contains(v, m) { + return true + } + } + // Also reject anything that doesn't look like x.y.z — any non-release + // build (e.g. a goreleaser snapshot tag like "0.0.1-next") would + // otherwise trigger a spurious upgrade prompt. + return !versionRE.MatchString(v) +} + +func fetchRemote(client *http.Client, url string) string { + resp, err := client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + return strings.TrimSpace(string(body)) +} + +// readCache loads the cache file and returns a Result if the entry is +// still within its TTL and refers to the current local version. +func readCache(stateDir, local string, now time.Time) (Result, bool) { + path := filepath.Join(stateDir, CacheFilename) + body, err := os.ReadFile(path) + if err != nil { + return Result{}, false + } + info, err := os.Stat(path) + if err != nil { + return Result{}, false + } + parts := strings.Fields(strings.TrimSpace(string(body))) + if len(parts) == 0 { + return Result{}, false + } + switch parts[0] { + case "UP_TO_DATE": + if len(parts) != 2 || parts[1] != local { + return Result{}, false + } + if now.Sub(info.ModTime()) > UpToDateTTL { + return Result{}, false + } + return Result{Status: StatusUpToDate, Local: local}, true + case "UPGRADE_AVAILABLE": + if len(parts) != 3 || parts[1] != local { + return Result{}, false + } + if now.Sub(info.ModTime()) > UpgradeAvailableTTL { + return Result{}, false + } + return Result{Status: StatusUpgradeAvailable, Local: local, Remote: parts[2]}, true + } + return Result{}, false +} + +func writeCache(stateDir, line string) error { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(stateDir, CacheFilename), []byte(line+"\n"), 0o644) +} + +type snoozeEntry struct { + version string + level int + epoch int64 +} + +func readSnoozeFile(stateDir string) (snoozeEntry, error) { + body, err := os.ReadFile(filepath.Join(stateDir, SnoozeFilename)) + if err != nil { + return snoozeEntry{}, err + } + parts := strings.Fields(strings.TrimSpace(string(body))) + if len(parts) != 3 { + return snoozeEntry{}, errors.New("malformed snooze file") + } + level, err := strconv.Atoi(parts[1]) + if err != nil { + return snoozeEntry{}, err + } + epoch, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return snoozeEntry{}, err + } + return snoozeEntry{version: parts[0], level: level, epoch: epoch}, nil +} + +func isSnoozed(stateDir, remote string, now time.Time) bool { + entry, err := readSnoozeFile(stateDir) + if err != nil { + return false + } + if entry.version != remote { + return false + } + var window time.Duration + switch { + case entry.level <= 1: + window = 24 * time.Hour + case entry.level == 2: + window = 48 * time.Hour + default: + window = 7 * 24 * time.Hour + } + expires := time.Unix(entry.epoch, 0).Add(window) + return now.Before(expires) +} diff --git a/internal/updatecheck/updatecheck_test.go b/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..c436560 --- /dev/null +++ b/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,383 @@ +package updatecheck + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// fixedNow returns a Now() func locked to a specific instant. +func fixedNow(ts time.Time) func() time.Time { + return func() time.Time { return ts } +} + +// staticEnv returns an Env() func that ignores os.Getenv and returns +// values from a fixed map. Tests use this to set DOCOPS_UPDATE_CHECK +// without leaking into the parent process. +func staticEnv(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +// serveVersion stands up an httptest.Server that returns body for any +// GET. Returns the server URL and a teardown func. +func serveVersion(t *testing.T, body string) (string, func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + return srv.URL, srv.Close +} + +// failIfHit returns a server URL whose handler fails the test if it +// receives any request. +func failIfHit(t *testing.T) (string, func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("network was hit unexpectedly: %s", r.URL.Path) + })) + return srv.URL, srv.Close +} + +func TestRun_RequiresLocal(t *testing.T) { + if _, err := Run(Opts{}); err == nil { + t.Fatal("expected error when Local is empty") + } +} + +func TestRun_DevBuildSkipsWithoutNetwork(t *testing.T) { + url, stop := failIfHit(t) + defer stop() + + for _, local := range []string{"dev", "0.1.2+dirty", "v0.1.2-(devel)", "0.0.1-next"} { + t.Run(local, func(t *testing.T) { + res, err := Run(Opts{ + Local: local, + RemoteURL: url, + StateDir: t.TempDir(), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "dev-build" { + t.Errorf("got %+v; want StatusSkipped/dev-build", res) + } + }) + } +} + +func TestRun_DisabledByEnvSkipsWithoutNetwork(t *testing.T) { + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: t.TempDir(), + Env: staticEnv(map[string]string{"DOCOPS_UPDATE_CHECK": "off"}), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "disabled" { + t.Errorf("got %+v; want StatusSkipped/disabled", res) + } +} + +func TestRun_FreshUpToDateCacheHit_NoNetwork(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate", res) + } +} + +func TestRun_FreshUpgradeAvailableCacheHit_NoNetwork(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UPGRADE_AVAILABLE 0.1.1 0.1.2\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestRun_StaleCacheTriggersRefetch(t *testing.T) { + state := t.TempDir() + cachePath := filepath.Join(state, CacheFilename) + if err := os.WriteFile(cachePath, []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + // Force the cache to look 12 hours old (UP_TO_DATE TTL is 6h). + old := time.Now().Add(-12 * time.Hour) + if err := os.Chtimes(cachePath, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestRun_NetworkErrorYieldsUpToDate(t *testing.T) { + // Point at an address with no listener — http.Client.Get will fail + // with a connection refused. + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: "http://127.0.0.1:1/VERSION", + StateDir: t.TempDir(), + Timeout: 200 * time.Millisecond, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate on network error", res) + } +} + +func TestRun_InvalidRemoteResponseYieldsUpToDate(t *testing.T) { + cases := map[string]string{ + "empty": "", + "html": "404", + "prefixed": "v0.1.2", + "too-short": "0.1", + "with-space": "0.1.2 extra", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + url, stop := serveVersion(t, body) + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: t.TempDir(), + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("body=%q got %+v; want StatusUpToDate", body, res) + } + }) + } +} + +func TestRun_UpToDateMatchingRemote(t *testing.T) { + url, stop := serveVersion(t, "0.1.1\n") + defer stop() + state := t.TempDir() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate", res) + } + cached, err := os.ReadFile(filepath.Join(state, CacheFilename)) + if err != nil || string(cached) != "UP_TO_DATE 0.1.1\n" { + t.Errorf("cache file = %q (err=%v); want %q", cached, err, "UP_TO_DATE 0.1.1\n") + } +} + +func TestRun_SnoozeSuppressesMatchingRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", now.Unix())), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "snoozed" { + t.Errorf("got %+v; want StatusSkipped/snoozed", res) + } +} + +func TestRun_SnoozeForOlderRemoteDoesNotSuppressNewer(t *testing.T) { + state := t.TempDir() + now := time.Now() + // Snooze 0.1.2; remote will offer 0.1.3 — should NOT be suppressed. + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", now.Unix())), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.3\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.3" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.3", res) + } +} + +func TestRun_ExpiredSnoozeNoLongerSuppresses(t *testing.T) { + state := t.TempDir() + now := time.Now() + // level 1 = 24h; snooze stamped 48h ago is expired. + stamped := now.Add(-48 * time.Hour).Unix() + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", stamped)), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable { + t.Errorf("got %+v; want StatusUpgradeAvailable after snooze expired", res) + } +} + +func TestRun_ForceBypassesCache(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Force: true, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestSnooze_BumpsLevelOnRepeatForSameRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := Snooze(state, "0.1.2", now); err != nil { + t.Fatalf("first snooze: %v", err) + } + if err := Snooze(state, "0.1.2", now.Add(time.Minute)); err != nil { + t.Fatalf("second snooze: %v", err) + } + entry, err := readSnoozeFile(state) + if err != nil { + t.Fatalf("readSnoozeFile: %v", err) + } + if entry.level != 2 { + t.Errorf("level = %d; want 2 after second snooze of same remote", entry.level) + } +} + +func TestSnooze_ResetsLevelOnNewRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := Snooze(state, "0.1.2", now); err != nil { + t.Fatalf("first snooze: %v", err) + } + if err := Snooze(state, "0.1.3", now); err != nil { + t.Fatalf("snooze for new remote: %v", err) + } + entry, err := readSnoozeFile(state) + if err != nil { + t.Fatalf("readSnoozeFile: %v", err) + } + if entry.version != "0.1.3" || entry.level != 1 { + t.Errorf("entry = %+v; want version=0.1.3 level=1", entry) + } +} diff --git a/templates/skills_lint_test.go b/templates/skills_lint_test.go index d843593..9acaf64 100644 --- a/templates/skills_lint_test.go +++ b/templates/skills_lint_test.go @@ -20,14 +20,15 @@ import ( // knownSubcmds is the shipped CLI surface for v0.1.1. var knownSubcmds = map[string]bool{ - "init": true, - "validate": true, - "index": true, - "state": true, - "audit": true, - "new": true, - "schema": true, - "refresh": true, + "init": true, + "validate": true, + "index": true, + "state": true, + "audit": true, + "new": true, + "schema": true, + "refresh": true, + "update-check": true, } // knownNewKinds are valid arguments for `docops new`. From c0393f72e5d59f60f3169e6accfbaf97281f49e7 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 01:38:44 +0530 Subject: [PATCH 06/11] =?UTF-8?q?TP-018:=20docops=20upgrade=20=E2=80=94=20?= =?UTF-8?q?targeted=20scaffold=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the in-band upgrade path specified by ADR-0021. After brew/scoop bumps the binary, `docops upgrade` syncs the shipped skills, schemas, and the AGENTS.md docops block in place — no clobbering of docops.yaml or the pre-commit hook unless the user opts in via --config or --hook. Removed-skill deletion is scoped to the .claude/skills/docops/ and .cursor/commands/docops/ subdirectories. A .docops-manifest sentinel written on first successful run lets subsequent upgrades distinguish "shipped removal" from "user added a custom skill"; the latter trips a refusal (exit 2) so user files are never silently deleted. Wires update-check (TP-021) into the upgrade pre-flight: a stale binary triggers a warning + interactive prompt before any sync runs, so users do not ship the older templates after forgetting to brew upgrade. --yes / non-TTY stdin proceed silently. cmd_upgrade ships --dry-run, --yes/-y, --config, --hook, --json with the action vocabulary promised by ADR-0021 (new, refreshed, removed, up-to-date, block-refreshed). scaffold gains KindRemove + Execute support. New internal/upgrader/ package + tests + cmd-level tests + templates/skills/docops/upgrade.md + README "Upgrading" subsection + AGENTS.md.tmpl mention + skills lint update. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 ++ cmd/docops/cmd_upgrade.go | 273 +++++++++++++++++ cmd/docops/cmd_upgrade_test.go | 161 ++++++++++ cmd/docops/main.go | 3 + internal/scaffold/scaffold.go | 9 +- internal/upgrader/manifest.go | 59 ++++ internal/upgrader/upgrader.go | 453 +++++++++++++++++++++++++++++ internal/upgrader/upgrader_test.go | 354 ++++++++++++++++++++++ templates/AGENTS.md.tmpl | 2 + templates/skills/docops/upgrade.md | 51 ++++ templates/skills_lint_test.go | 13 + 11 files changed, 1394 insertions(+), 1 deletion(-) create mode 100644 cmd/docops/cmd_upgrade.go create mode 100644 cmd/docops/cmd_upgrade_test.go create mode 100644 internal/upgrader/manifest.go create mode 100644 internal/upgrader/upgrader.go create mode 100644 internal/upgrader/upgrader_test.go create mode 100644 templates/skills/docops/upgrade.md diff --git a/README.md b/README.md index 0de35a8..3faef56 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ A GHCR image lands in a follow-up release. Until then, use Homebrew, Scoop, or d Per-platform packages (`@docops/cli-darwin-arm64`, `@docops/cli-linux-x64`, ...) will publish alongside a future release. `npm i -g @docops/cli` resolves the matching native binary via `optionalDependencies` — no postinstall network fetch. See ADR-0012 for distribution rationale. +### Upgrading an existing project + +After `brew upgrade docops` (or your package manager equivalent), pull the +new binary's shipped templates into your project without clobbering +`docops.yaml` or your pre-commit hook: + +```sh +brew upgrade docops # or scoop update docops, etc. +docops upgrade # syncs skills, schemas, AGENTS.md block +docops upgrade --dry-run # preview first if you prefer +``` + +`docops upgrade` only touches DocOps-owned scaffolding. To also rewrite +`docops.yaml` or reinstall the pre-commit hook, opt in with `--config` +or `--hook`. Run `docops update-check` (or wait for `docops upgrade` to +warn you on its own) to learn when a new release is available. + ## Smoke test ```sh diff --git a/cmd/docops/cmd_upgrade.go b/cmd/docops/cmd_upgrade.go new file mode 100644 index 0000000..aa89e8e --- /dev/null +++ b/cmd/docops/cmd_upgrade.go @@ -0,0 +1,273 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/internal/updatecheck" + "github.com/logicwind/docops/internal/upgrader" + "github.com/logicwind/docops/internal/version" + "golang.org/x/term" +) + +// cmdUpgrade implements `docops upgrade [--dry-run] [--yes] [--config] +// [--hook] [--json]`. Exit codes: +// +// 0 upgrade applied (or dry-run rendered, or user aborted) +// 2 bootstrap error (no docops.yaml, safety belt fired, IO error) +func cmdUpgrade(args []string) int { + fs := flag.NewFlagSet("upgrade", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + dryRun := fs.Bool("dry-run", false, "print the planned changes without writing") + cfg := fs.Bool("config", false, "also overwrite docops.yaml from the shipped template") + hook := fs.Bool("hook", false, "also reinstall the pre-commit hook") + asJSON := fs.Bool("json", false, "emit a JSON action plan instead of human output") + yes := fs.Bool("yes", false, "skip the interactive confirm prompt") + fs.BoolVar(yes, "y", false, "skip the interactive confirm prompt (short form)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops upgrade [--dry-run] [--yes] [--config] [--hook] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "docops upgrade: %v\n", err) + return 2 + } + + // Plan first. We always run the planner in dry-run mode internally + // so we can decide whether to prompt and what to print before + // committing to disk. + plan, err := upgrader.Run(upgrader.Options{ + Root: cwd, + DryRun: true, + Config: *cfg, + Hook: *hook, + Out: io.Discard, + }) + if err != nil { + return reportUpgradeError(err) + } + + if *asJSON { + emitUpgradeJSON(os.Stdout, plan.Actions) + if *dryRun { + return 0 + } + } else { + // Pre-flight stale-binary warning. Free on cache hit; one + // 5s network round-trip on a cold cache. Surfaces only when + // the user's binary is behind the latest release. + warnIfStaleBinary(os.Stdout, *yes) + printUpgradeHeader(os.Stdout, cwd, *cfg, *hook) + // Re-print the plan via the upgrader's own renderer (we + // discarded it earlier so we could decide on the prompt + // first). + printUpgradePlan(os.Stdout, plan.Actions, true) + } + + if *dryRun { + return 0 + } + + nonSkip := 0 + for _, a := range plan.Actions { + if a.Kind != scaffold.KindSkip { + nonSkip++ + } + } + + if !*asJSON && nonSkip > 0 && !*yes && term.IsTerminal(int(os.Stdin.Fd())) { + if !confirm(os.Stdout, os.Stdin, "Proceed?") { + fmt.Fprintln(os.Stdout, "docops upgrade: aborted by user.") + return 0 + } + } + + // Execute for real. The upgrader prints its own plan again; for + // JSON callers we already emitted, so route human output to + // stdout and structured output to a discard sink. + out := io.Writer(os.Stdout) + if *asJSON { + out = io.Discard + } + if _, err := upgrader.Run(upgrader.Options{ + Root: cwd, + DryRun: false, + Config: *cfg, + Hook: *hook, + Out: out, + }); err != nil { + return reportUpgradeError(err) + } + return 0 +} + +func reportUpgradeError(err error) int { + switch { + case errors.Is(err, upgrader.ErrNoConfig): + fmt.Fprintln(os.Stderr, "docops upgrade: no docops.yaml in the current directory.") + fmt.Fprintln(os.Stderr, " Run `docops init` first to scaffold the project.") + return 2 + default: + var unk *upgrader.ErrUnknownFiles + if errors.As(err, &unk) { + fmt.Fprintf(os.Stderr, "docops upgrade: %s contains user-added files docops did not write:\n", unk.Dir) + for _, f := range unk.Files { + fmt.Fprintf(os.Stderr, " - %s\n", f) + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Move them one level up (e.g. into .claude/skills/) so docops upgrade can") + fmt.Fprintln(os.Stderr, "manage its own subdirectory cleanly. See ADR-0021 for the rationale.") + return 2 + } + fmt.Fprintf(os.Stderr, "docops upgrade: %v\n", err) + return 2 + } +} + +func printUpgradeHeader(w io.Writer, cwd string, optConfig, optHook bool) { + fmt.Fprintf(w, "docops upgrade will refresh DocOps-owned scaffolding in %s:\n", cwd) + fmt.Fprintln(w, " - .claude/skills/docops/* and .cursor/commands/docops/* (replaced/removed to match the shipped bundle)") + fmt.Fprintln(w, " - docs/.docops/schema/*.schema.json (regenerated from docops.yaml)") + fmt.Fprintln(w, " - The block in AGENTS.md (refreshed in place)") + if optConfig { + fmt.Fprintln(w, " - docops.yaml (overwritten — --config)") + } + if optHook { + fmt.Fprintln(w, " - .git/hooks/pre-commit (reinstalled — --hook)") + } + fmt.Fprintln(w, "Your docops.yaml, pre-commit hook, and docs/{context,decisions,tasks}/ stay untouched by default.") +} + +// printUpgradePlan delegates to the upgrader's Run-time renderer by +// re-running the formatting locally. We duplicate the few lines of +// rendering rather than expose internals because the cmd-level header +// + plan + footer is not the upgrader's responsibility. +func printUpgradePlan(w io.Writer, actions []scaffold.Action, dry bool) { + var changed, skipped int + for _, a := range actions { + if a.Kind == scaffold.KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "\ndocops upgrade: %s %d change(s), skipped %d\n", verb, changed, skipped) + for _, a := range actions { + sigil, label := upgradeSigilLabel(a) + fmt.Fprintf(w, " %s %-40s %s\n", sigil, a.Rel, label) + } +} + +// upgradeSigilLabel mirrors upgrader.upgradeSigil. Kept here to avoid +// exporting an internal helper just for cmd-level rendering; if the +// duplication grows past a third callsite, promote it. +func upgradeSigilLabel(a scaffold.Action) (string, string) { + switch a.Kind { + case scaffold.KindMkdir: + return "+", "(new dir)" + case scaffold.KindWriteFile: + if a.Reason == "create" || a.Reason == "install" { + return "+", "(new)" + } + return "~", "(refreshed)" + case scaffold.KindMergeAgents: + return "~", "(block refreshed)" + case scaffold.KindRemove: + return "-", "(removed)" + case scaffold.KindSkip: + return "=", "(up to date)" + } + return "?", "(unknown)" +} + +func emitUpgradeJSON(w io.Writer, actions []scaffold.Action) { + type action struct { + Path string `json:"path"` + Kind string `json:"kind"` + } + out := struct { + OK bool `json:"ok"` + Actions []action `json:"actions"` + }{OK: true} + for _, a := range actions { + out.Actions = append(out.Actions, action{Path: a.Rel, Kind: jsonKind(a)}) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(out) +} + +// jsonKind maps internal action kinds to the user-facing labels +// promised by ADR-0021's --json contract. +func jsonKind(a scaffold.Action) string { + switch a.Kind { + case scaffold.KindWriteFile: + if a.Reason == "create" || a.Reason == "install" { + return "new" + } + return "refreshed" + case scaffold.KindMergeAgents: + return "block-refreshed" + case scaffold.KindRemove: + return "removed" + case scaffold.KindSkip: + return "up-to-date" + case scaffold.KindMkdir: + return "new" + } + return a.Kind +} + +func confirm(w io.Writer, r io.Reader, prompt string) bool { + fmt.Fprintf(w, "\n%s [y/N] ", prompt) + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(scanner.Text()) + return answer == "y" || answer == "Y" +} + +// warnIfStaleBinary runs the cached update-check and, if the binary +// is behind upstream, prints a warning + interactive prompt. Honors +// `--yes` (proceed without prompting) and non-TTY stdin (proceed +// silently — CI must keep flowing). +func warnIfStaleBinary(w io.Writer, yes bool) { + res, err := updatecheck.Run(updatecheck.Opts{Local: version.Version}) + if err != nil { + return + } + if res.Status != updatecheck.StatusUpgradeAvailable { + return + } + fmt.Fprintf(w, "Warning: docops %s is installed; %s is available.\n", res.Local, res.Remote) + fmt.Fprintln(w, " Run `brew upgrade docops` (or your package manager equivalent)") + fmt.Fprintln(w, " before `docops upgrade`, or you'll sync the older templates.") + fmt.Fprintln(w, "") + if yes || !term.IsTerminal(int(os.Stdin.Fd())) { + return + } + if !confirm(w, os.Stdin, fmt.Sprintf("Continue with %s templates anyway?", res.Local)) { + fmt.Fprintln(w, "docops upgrade: aborted by user (binary upgrade pending).") + os.Exit(0) + } +} diff --git a/cmd/docops/cmd_upgrade_test.go b/cmd/docops/cmd_upgrade_test.go new file mode 100644 index 0000000..cbe3e8b --- /dev/null +++ b/cmd/docops/cmd_upgrade_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/templates" +) + +// upgradeFixture builds a minimal docops-initialized tempdir, chdirs +// into it for the test, and returns the absolute path. cmdUpgrade +// reads cwd, so the chdir is what couples it to the fixture. +func upgradeFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + + yamlBody, err := templates.DocopsYAML() + if err != nil { + t.Fatalf("template DocopsYAML: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "docops.yaml"), yamlBody, 0o644); err != nil { + t.Fatalf("write docops.yaml: %v", err) + } + + skills, err := scaffold.LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for name, body := range skills { + if err := os.WriteFile(filepath.Join(root, dir, name), body, 0o644); err != nil { + t.Fatalf("seed skill: %v", err) + } + } + } + + prevDir, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir fixture: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevDir) }) + + return root +} + +func TestCmdUpgrade_RefusesWithoutDocopsYAML(t *testing.T) { + root := t.TempDir() + prevDir, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevDir) }) + + code := cmdUpgrade([]string{"--dry-run"}) + if code != 2 { + t.Errorf("exit = %d; want 2 when docops.yaml is missing", code) + } +} + +func TestCmdUpgrade_DryRunWritesNothing(t *testing.T) { + root := upgradeFixture(t) + stale := filepath.Join(root, ".claude/skills/docops/next.md") + if err := os.WriteFile(stale, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + code := cmdUpgrade([]string{"--dry-run"}) + if code != 0 { + t.Errorf("exit = %d; want 0", code) + } + if _, err := os.Stat(stale); err != nil { + t.Errorf("dry-run should not delete stale skill: %v", err) + } +} + +func TestCmdUpgrade_JSONShape(t *testing.T) { + upgradeFixture(t) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + prevStdout := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = prevStdout }) + + code := cmdUpgrade([]string{"--dry-run", "--json"}) + w.Close() + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + t.Fatalf("read: %v", err) + } + + var payload struct { + OK bool `json:"ok"` + Actions []struct { + Path string `json:"path"` + Kind string `json:"kind"` + } `json:"actions"` + } + if err := json.Unmarshal(buf.Bytes(), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nbody=%s", err, buf.String()) + } + if !payload.OK { + t.Errorf("payload.OK = false") + } + if len(payload.Actions) == 0 { + t.Errorf("expected non-empty actions list") + } + allowed := map[string]bool{"new": true, "refreshed": true, "removed": true, "up-to-date": true, "block-refreshed": true} + for _, a := range payload.Actions { + if !allowed[a.Kind] { + t.Errorf("unexpected action kind %q for %s", a.Kind, a.Path) + } + } +} + +func TestCmdUpgrade_YesFlagSkipsPromptAndApplies(t *testing.T) { + root := upgradeFixture(t) + stale := filepath.Join(root, ".claude/skills/docops/next.md") + if err := os.WriteFile(stale, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + + // Redirect stdout so the (potentially non-tty) prompt and plan + // don't pollute test output. We can't actually drive the prompt + // from a test without a TTY, but --yes bypasses the prompt + // entirely so the apply runs unconditionally. + r, w, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = prevStdout }) + + code := cmdUpgrade([]string{"--yes"}) + w.Close() + if code != 0 { + t.Errorf("exit = %d; want 0", code) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("--yes should have applied the upgrade and removed the stale skill: %v", err) + } + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + out := buf.String() + if !strings.Contains(out, "docops upgrade:") { + t.Errorf("expected output summary line; got %q", out) + } +} diff --git a/cmd/docops/main.go b/cmd/docops/main.go index c192118..db1a00f 100644 --- a/cmd/docops/main.go +++ b/cmd/docops/main.go @@ -47,6 +47,8 @@ func main() { os.Exit(cmdSearch(args[1:])) case "update-check": os.Exit(cmdUpdateCheck(args[1:])) + case "upgrade": + os.Exit(cmdUpgrade(args[1:])) default: fmt.Fprintf(os.Stderr, "docops: unknown command %q\n\n", args[0]) topLevelUsage(os.Stderr) @@ -61,6 +63,7 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, "") fmt.Fprintln(w, "commands:") fmt.Fprintln(w, " init scaffold DocOps in this repository") + fmt.Fprintln(w, " upgrade refresh DocOps-owned scaffolding in an existing project") fmt.Fprintln(w, " validate schema + graph invariants over docs/") fmt.Fprintln(w, " index build docs/.index.json enriched graph") fmt.Fprintln(w, " state regenerate docs/STATE.md snapshot") diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 8ebac5f..2acaec8 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -23,6 +23,7 @@ const ( KindMkdir = "mkdir" KindWriteFile = "write-file" KindMergeAgents = "merge-agents" + KindRemove = "remove" KindSkip = "skip" ) @@ -150,7 +151,8 @@ func ExtractBlock(tmpl []byte) string { // Execute applies one planned action to the filesystem. Skips are // no-ops; mkdir creates with 0o755; writes ensure parent dirs exist -// and use the action's Mode. +// and use the action's Mode; remove deletes the file (a missing file +// is not an error). func Execute(a *Action) error { switch a.Kind { case KindSkip: @@ -162,6 +164,11 @@ func Execute(a *Action) error { return err } return os.WriteFile(a.Path, a.Body, a.Mode) + case KindRemove: + if err := os.Remove(a.Path); err != nil && !os.IsNotExist(err) { + return err + } + return nil } return fmt.Errorf("unknown action kind %q", a.Kind) } diff --git a/internal/upgrader/manifest.go b/internal/upgrader/manifest.go new file mode 100644 index 0000000..973eff8 --- /dev/null +++ b/internal/upgrader/manifest.go @@ -0,0 +1,59 @@ +package upgrader + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "sort" + "strings" +) + +// ManifestFilename is the dot-prefixed sentinel docops writes inside +// each owned skill directory to record what it scaffolded. On +// subsequent upgrades, the manifest scopes deletion to "files we +// previously wrote" so user-added files inside the dir cause an +// explicit refusal rather than silent removal. +const ManifestFilename = ".docops-manifest" + +// readManifest returns the sorted list of basenames recorded in the +// dir's manifest. exists is false (and names is nil) when the +// manifest file is absent — first-run upgrades take this path. A +// blank or comment-only manifest yields an empty slice and exists=true. +func readManifest(absDir string) (names []string, exists bool, err error) { + body, err := os.ReadFile(filepath.Join(absDir, ManifestFilename)) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + sc := bufio.NewScanner(bytes.NewReader(body)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + names = append(names, line) + } + sort.Strings(names) + return names, true, sc.Err() +} + +// writeManifest replaces the manifest file with a sorted list of the +// given basenames, prefixed with a comment explaining what the file +// is. Best-effort: callers should warn but not fail if this errors. +func writeManifest(absDir string, names []string) error { + if err := os.MkdirAll(absDir, 0o755); err != nil { + return err + } + sort.Strings(names) + var buf bytes.Buffer + buf.WriteString("# docops manifest — files in this directory owned by `docops upgrade`.\n") + buf.WriteString("# Do not hand-edit; rerun `docops upgrade` to regenerate.\n") + for _, n := range names { + buf.WriteString(n) + buf.WriteByte('\n') + } + return os.WriteFile(filepath.Join(absDir, ManifestFilename), buf.Bytes(), 0o644) +} diff --git a/internal/upgrader/upgrader.go b/internal/upgrader/upgrader.go new file mode 100644 index 0000000..e5e68f6 --- /dev/null +++ b/internal/upgrader/upgrader.go @@ -0,0 +1,453 @@ +// Package upgrader implements `docops upgrade`: an in-band refresh of +// docops-owned scaffolding (skills, schemas, AGENTS.md block) for +// projects already initialized via `docops init`. The contract is +// narrower than init's: we never touch user-owned files +// (docops.yaml, pre-commit hook, docs/{context,decisions,tasks}/*) +// unless an opt-in flag asks us to. See ADR-0021 for the rationale. +package upgrader + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/internal/schema" + "github.com/logicwind/docops/templates" +) + +// Options configures an upgrade run. Root must contain a docops.yaml +// (Run rejects the call otherwise so users do not accidentally +// scaffold a fresh project via the wrong subcommand). +type Options struct { + Root string + DryRun bool + // Config opts in to overwriting docops.yaml from the shipped template. + Config bool + // Hook opts in to reinstalling the pre-commit hook. + Hook bool + // Out is the human-readable progress sink; defaults to os.Stdout. + Out io.Writer +} + +// Result mirrors initter.Result: the planned/applied actions in order. +type Result struct { + Actions []scaffold.Action +} + +// ErrNoConfig signals the project is not docops-initialized yet. The +// CLI converts this into a clear "run docops init first" message and +// exits 2 (matching every other non-init bootstrap error). +var ErrNoConfig = errors.New("upgrader: no docops.yaml at root — run `docops init` first") + +// ErrUnknownFiles signals the safety belt fired: the docops-owned +// skill directory contains files that neither the manifest nor the +// shipped bundle accounts for. The CLI prints the file list verbatim +// and exits 2 without touching anything. +type ErrUnknownFiles struct { + Dir string + Files []string +} + +func (e *ErrUnknownFiles) Error() string { + return fmt.Sprintf("upgrader: %s contains user-added files not in the docops bundle: %v", e.Dir, e.Files) +} + +// Run plans and (unless DryRun) executes the upgrade. Returns +// ErrNoConfig if the root has no docops.yaml; *ErrUnknownFiles if a +// docops-owned skill dir contains files outside the shipped bundle +// and the manifest. +func Run(opts Options) (*Result, error) { + if opts.Root == "" { + return nil, errors.New("upgrader: Root must be set") + } + abs, err := filepath.Abs(opts.Root) + if err != nil { + return nil, fmt.Errorf("upgrader: resolve root: %w", err) + } + opts.Root = abs + if opts.Out == nil { + opts.Out = os.Stdout + } + + cfgPath := filepath.Join(opts.Root, config.DefaultFilename) + if _, err := os.Stat(cfgPath); err != nil { + if os.IsNotExist(err) { + return nil, ErrNoConfig + } + return nil, fmt.Errorf("upgrader: stat %s: %w", config.DefaultFilename, err) + } + + actions, err := plan(opts) + if err != nil { + return nil, err + } + + if opts.DryRun { + printPlan(opts.Out, actions, true) + return &Result{Actions: actions}, nil + } + for i := range actions { + if err := scaffold.Execute(&actions[i]); err != nil { + return nil, fmt.Errorf("apply %s: %w", actions[i].Rel, err) + } + } + // After successful execution, refresh the manifest in each + // docops-owned skill dir so subsequent upgrades scope deletions + // correctly. Failures here are logged but do not fail the upgrade + // — manifest is best-effort metadata. + for _, dir := range docopsSkillDirs() { + if err := writeManifest(filepath.Join(opts.Root, dir), shippedSkillNames(actions, dir)); err != nil { + fmt.Fprintf(opts.Out, " warning: refresh manifest in %s: %v\n", dir, err) + } + } + printPlan(opts.Out, actions, false) + return &Result{Actions: actions}, nil +} + +// docopsSkillDirs lists the directories upgrade owns. A future plugin +// system might extend this; for now it is fixed to the two +// agent-tool conventions docops init scaffolds. +func docopsSkillDirs() []string { + return []string{".claude/skills/docops", ".cursor/commands/docops"} +} + +// shippedSkillNames returns the basenames of skill files currently +// present (write or refresh actions) in the given dir, derived from +// the action set. Used to write the post-upgrade manifest. +func shippedSkillNames(actions []scaffold.Action, dir string) []string { + prefix := dir + string(filepath.Separator) + var names []string + for _, a := range actions { + if a.Kind == scaffold.KindRemove { + continue + } + // Only top-level files under the dir count — skip the dir + // itself and any nested paths (none today, future-proof). + if rel := a.Rel; len(rel) > len(prefix) && rel[:len(prefix)] == prefix { + name := rel[len(prefix):] + if !containsSep(name) { + names = append(names, name) + } + } + } + sort.Strings(names) + return names +} + +func containsSep(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == filepath.Separator || s[i] == '/' { + return true + } + } + return false +} + +// plan assembles the full upgrade action set. Reads the project's +// docops.yaml so context_types propagate into emitted schemas. +func plan(opts Options) ([]scaffold.Action, error) { + cfg := config.Default() + if loaded, err := config.Load(filepath.Join(opts.Root, config.DefaultFilename)); err == nil { + cfg = loaded + } + + var actions []scaffold.Action + + // 1. JSON Schemas — always refreshed (docops-owned). + schemas, err := schema.JSONSchemas(schema.Config{ContextTypes: cfg.ContextTypes}) + if err != nil { + return nil, fmt.Errorf("emit json schema: %w", err) + } + schemaNames := make([]string, 0, len(schemas)) + for name := range schemas { + schemaNames = append(schemaNames, name) + } + sort.Strings(schemaNames) + for _, name := range schemaNames { + rel := filepath.Join(cfg.Paths.Schema, name) + actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, true)) + } + + // 2. AGENTS.md block refresh (only if file exists). + agentsTmpl, err := templates.AgentsBlock() + if err != nil { + return nil, fmt.Errorf("read agents template: %w", err) + } + agentsAction, err := planAgents(opts, agentsTmpl) + if err != nil { + return nil, err + } + if agentsAction != nil { + actions = append(actions, *agentsAction) + } + + // 3. Skills — sync each docops-owned dir against the shipped bundle. + skills, err := scaffold.LoadShippedSkills() + if err != nil { + return nil, fmt.Errorf("read skills: %w", err) + } + skillNames := make([]string, 0, len(skills)) + for name := range skills { + skillNames = append(skillNames, name) + } + sort.Strings(skillNames) + + for _, dir := range docopsSkillDirs() { + dirActions, err := planSkillDir(opts, dir, skills, skillNames) + if err != nil { + return nil, err + } + actions = append(actions, dirActions...) + } + + // 4. Opt-in: docops.yaml. + if opts.Config { + yamlBody, err := templates.DocopsYAML() + if err != nil { + return nil, fmt.Errorf("read docops.yaml template: %w", err) + } + actions = append(actions, scaffold.FileAction(opts.Root, "docops.yaml", yamlBody, 0o644, true)) + } + + // 5. Opt-in: pre-commit hook. + if opts.Hook { + hookBody, err := templates.PreCommitHook() + if err != nil { + return nil, fmt.Errorf("read pre-commit template: %w", err) + } + hookAction, err := planHook(opts, hookBody) + if err != nil { + return nil, err + } + actions = append(actions, hookAction) + } + + return actions, nil +} + +// planAgents returns a refresh action for AGENTS.md if the file +// exists and the docops block is stale or absent. Returns nil if +// AGENTS.md doesn't exist (upgrade does not create it; that's init's +// job). +func planAgents(opts Options, tmpl []byte) (*scaffold.Action, error) { + rel := "AGENTS.md" + abs := filepath.Join(opts.Root, rel) + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + merged, changed, reason := scaffold.MergeAgentsBlock(existing, tmpl) + if !changed { + return &scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindSkip, Reason: reason}, nil + } + return &scaffold.Action{ + Path: abs, + Rel: rel, + Kind: scaffold.KindMergeAgents, + Body: merged, + Mode: 0o644, + Reason: reason, + }, nil +} + +// planHook installs/refreshes .git/hooks/pre-commit. Only invoked +// when --hook is passed; otherwise upgrade leaves the user's hook +// chain alone. +func planHook(opts Options, body []byte) (scaffold.Action, error) { + gitDir := filepath.Join(opts.Root, ".git") + if info, err := os.Stat(gitDir); err != nil || !info.IsDir() { + return scaffold.Action{ + Path: filepath.Join(gitDir, "hooks", "pre-commit"), + Rel: ".git/hooks/pre-commit", + Kind: scaffold.KindSkip, + Reason: "no .git directory — install the hook manually from templates/hooks/pre-commit", + }, nil + } + rel := ".git/hooks/pre-commit" + abs := filepath.Join(opts.Root, rel) + a := scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindWriteFile, Body: body, Mode: 0o755} + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + a.Reason = "install" + return a, nil + } + return scaffold.Action{}, err + } + if bytes.Equal(existing, body) { + a.Kind = scaffold.KindSkip + a.Reason = "already up to date" + return a, nil + } + a.Reason = "overwrite drifted hook (--hook)" + return a, nil +} + +// planSkillDir walks one docops-owned skill directory and emits the +// create / refresh / remove / skip actions to bring it in line with +// the shipped bundle. The safety belt (manifest check) fires here: +// any present file that the manifest does not vouch for and the +// shipped bundle does not contain triggers ErrUnknownFiles. +func planSkillDir(opts Options, dir string, shipped map[string][]byte, shippedNames []string) ([]scaffold.Action, error) { + absDir := filepath.Join(opts.Root, dir) + + present, dirExists, err := listSkillFiles(absDir) + if err != nil { + return nil, err + } + + manifest, manifestExists, err := readManifest(absDir) + if err != nil { + return nil, err + } + + // Safety belt: only enforce when a manifest exists. First-time + // upgraders (v0.1.x users) get an implicit pass — anything in the + // dir is treated as docops-owned and reconciled against the + // shipped bundle. + if manifestExists { + shippedSet := stringSet(shippedNames) + manifestSet := stringSet(manifest) + var unknown []string + for _, name := range present { + if !shippedSet[name] && !manifestSet[name] { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, &ErrUnknownFiles{Dir: dir, Files: unknown} + } + } + + var actions []scaffold.Action + + // Ensure the directory exists (mkdir or skip). + actions = append(actions, scaffold.DirAction(opts.Root, dir)) + + // Refresh / create every shipped file. + for _, name := range shippedNames { + rel := filepath.Join(dir, name) + actions = append(actions, scaffold.FileAction(opts.Root, rel, shipped[name], 0o644, true)) + } + + // Remove anything that is in the dir but not in the shipped bundle. + // On first run (no manifest), this includes user files inside + // docops/ — by design (see ADR-0021: "DocOps owns the docops/ + // subdirectory"). On subsequent runs, the safety belt above + // already vetted the dir, so removals are scoped to manifest ∩ + // (not shipped) — i.e. genuine shipped removals. + if dirExists { + shippedSet := stringSet(shippedNames) + for _, name := range present { + if shippedSet[name] { + continue + } + rel := filepath.Join(dir, name) + actions = append(actions, scaffold.Action{ + Path: filepath.Join(opts.Root, rel), + Rel: rel, + Kind: scaffold.KindRemove, + Reason: "no longer in shipped bundle", + Mode: 0o644, + }) + } + } + + // Also drop the manifest sentinel from "present" before any output + // — it is internal metadata, not a skill file. listSkillFiles + // already excludes dotfiles; nothing to do here. + + return actions, nil +} + +// listSkillFiles returns the basenames of regular files directly +// inside dir (no recursion, no dotfiles). dirExists distinguishes a +// missing directory from an empty one so callers can choose to mkdir +// vs scan. +func listSkillFiles(dir string) (names []string, dirExists bool, err error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + name := e.Name() + if len(name) > 0 && name[0] == '.' { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names, true, nil +} + +func stringSet(xs []string) map[string]bool { + out := make(map[string]bool, len(xs)) + for _, x := range xs { + out[x] = true + } + return out +} + +// printPlan renders the action set with upgrade-specific sigils: +// `+` new, `~` refreshed, `-` removed, `=` up to date. Mirrors the +// shape promised by ADR-0021's "Output" section. +func printPlan(w io.Writer, actions []scaffold.Action, dry bool) { + var changed, skipped int + for _, a := range actions { + if a.Kind == scaffold.KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "docops upgrade: %s %d change(s), skipped %d\n", verb, changed, skipped) + for _, a := range actions { + sigil, label := upgradeSigil(a) + fmt.Fprintf(w, " %s %-40s %s\n", sigil, a.Rel, label) + } +} + +// upgradeSigil maps a scaffold action to the sigil + parenthetical +// label used in upgrade's output. Init has its own (different) +// rendering; the two diverged because upgrade's output is a diff, +// init's is a creation log. +func upgradeSigil(a scaffold.Action) (string, string) { + switch a.Kind { + case scaffold.KindMkdir: + return "+", "(new dir)" + case scaffold.KindWriteFile: + // FileAction sets Reason="create" for new files, else + // "overwrite drifted content (--force)" or similar. + if a.Reason == "create" || a.Reason == "install" { + return "+", "(new)" + } + return "~", "(refreshed)" + case scaffold.KindMergeAgents: + return "~", "(block refreshed)" + case scaffold.KindRemove: + return "-", "(removed)" + case scaffold.KindSkip: + return "=", "(up to date)" + } + return "?", "(unknown)" +} diff --git a/internal/upgrader/upgrader_test.go b/internal/upgrader/upgrader_test.go new file mode 100644 index 0000000..c5cb6e1 --- /dev/null +++ b/internal/upgrader/upgrader_test.go @@ -0,0 +1,354 @@ +package upgrader + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/templates" +) + +// initted scaffolds a minimal docops-initialized project at root with +// the shipped skills and schemas pre-installed, mirroring what +// `docops init` would have produced one release earlier. The caller +// can then mutate the layout to simulate v0.1.x drift before running +// the upgrader. +func initted(t *testing.T) string { + t.Helper() + root := t.TempDir() + + // docops.yaml — required for the upgrader to run at all. + yamlBody, err := templates.DocopsYAML() + if err != nil { + t.Fatalf("template DocopsYAML: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "docops.yaml"), yamlBody, 0o644); err != nil { + t.Fatalf("write docops.yaml: %v", err) + } + + // Seed the shipped skills into both docops-owned dirs. + skills, err := scaffold.LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + for name, body := range skills { + if err := os.WriteFile(filepath.Join(root, dir, name), body, 0o644); err != nil { + t.Fatalf("write seed skill %s: %v", name, err) + } + } + } + + // AGENTS.md with the docops block already merged. + agentsTmpl, err := templates.AgentsBlock() + if err != nil { + t.Fatalf("template AgentsBlock: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), agentsTmpl, 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + + return root +} + +// findAction returns the first action matching rel, or nil. +func findAction(actions []scaffold.Action, rel string) *scaffold.Action { + for i, a := range actions { + if a.Rel == rel { + return &actions[i] + } + } + return nil +} + +func TestRun_RefusesWithoutDocopsYAML(t *testing.T) { + root := t.TempDir() // empty + _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if !errors.Is(err, ErrNoConfig) { + t.Fatalf("err = %v; want ErrNoConfig", err) + } +} + +func TestRun_IdempotentOnFreshlyInittedProject(t *testing.T) { + root := initted(t) + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run: %v", err) + } + for _, a := range res.Actions { + if a.Kind == scaffold.KindRemove { + t.Errorf("idempotent run should not remove anything; got remove for %s", a.Rel) + } + // The schema files were written by initted via the templates, + // but FileAction's body comparison may flag them refreshed + // because schema.JSONSchemas regenerates from docops.yaml. We + // don't seed them here, so a fresh run will emit creates for + // the schema files — that's fine. Skill files are seeded + // byte-identically and should be skips. + if strings.HasPrefix(a.Rel, ".claude/skills/docops/") && a.Kind != scaffold.KindMkdir && a.Kind != scaffold.KindSkip { + t.Errorf("seeded skill should be skip; %s = %s", a.Rel, a.Kind) + } + } +} + +func TestRun_AddsNewSkillRemovesStaleRefreshesChanged(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + + // Simulate v0.1.0-era state in the dir: + // - delete one shipped skill so the upgrader will (re)create it. + // - mutate one shipped skill so the upgrader will refresh it. + // - add a stale skill that no longer ships. + pickAdd := "init.md" // delete locally → upgrade should add (+) + pickRefresh := "audit.md" // mutate locally → upgrade should refresh (~) + if err := os.Remove(filepath.Join(dir, pickAdd)); err != nil { + t.Fatalf("remove %s: %v", pickAdd, err) + } + if err := os.WriteFile(filepath.Join(dir, pickRefresh), []byte("stale local body\n"), 0o644); err != nil { + t.Fatalf("mutate %s: %v", pickRefresh, err) + } + if err := os.WriteFile(filepath.Join(dir, "next.md"), []byte("removed-upstream skill\n"), 0o644); err != nil { + t.Fatalf("seed stale skill: %v", err) + } + + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + addAction := findAction(res.Actions, ".claude/skills/docops/"+pickAdd) + if addAction == nil || addAction.Kind != scaffold.KindWriteFile || addAction.Reason != "create" { + t.Errorf("%s should be a create action; got %+v", pickAdd, addAction) + } + + refreshAction := findAction(res.Actions, ".claude/skills/docops/"+pickRefresh) + if refreshAction == nil || refreshAction.Kind != scaffold.KindWriteFile || refreshAction.Reason == "create" { + t.Errorf("%s should be a refresh (overwrite) action; got %+v", pickRefresh, refreshAction) + } + + removeAction := findAction(res.Actions, ".claude/skills/docops/next.md") + if removeAction == nil || removeAction.Kind != scaffold.KindRemove { + t.Errorf("next.md should be a remove action; got %+v", removeAction) + } +} + +func TestRun_ApplyWritesFilesAndDeletesStale(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + staleName := "next.md" + if err := os.WriteFile(filepath.Join(dir, staleName), []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run apply: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, staleName)); !os.IsNotExist(err) { + t.Errorf("stale skill should be deleted; stat err = %v", err) + } + + // A second apply should be a clean no-op (idempotent). + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run dry-run after apply: %v", err) + } + for _, a := range res.Actions { + if a.Kind == scaffold.KindRemove || (a.Kind == scaffold.KindWriteFile && a.Reason != "create") { + // schema files are always written (regenerated from yaml); + // don't flag those as drift. + continue + } + } +} + +func TestRun_DoesNotTouchDocopsYAMLByDefault(t *testing.T) { + root := initted(t) + yamlPath := filepath.Join(root, "docops.yaml") + custom := []byte("# user-customized config\nproject: my-thing\n") + if err := os.WriteFile(yamlPath, custom, 0o644); err != nil { + t.Fatalf("write custom yaml: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + if !bytes.Equal(got, custom) { + t.Errorf("docops.yaml was modified without --config:\n%s", got) + } +} + +func TestRun_RewritesDocopsYAMLWithConfigFlag(t *testing.T) { + root := initted(t) + yamlPath := filepath.Join(root, "docops.yaml") + if err := os.WriteFile(yamlPath, []byte("# stale\n"), 0o644); err != nil { + t.Fatalf("seed stale yaml: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Config: true, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + if bytes.Equal(got, []byte("# stale\n")) { + t.Errorf("docops.yaml should have been rewritten with --config") + } +} + +func TestRun_DoesNotTouchPreCommitHookByDefault(t *testing.T) { + root := initted(t) + hookDir := filepath.Join(root, ".git/hooks") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatalf("mkdir hookdir: %v", err) + } + customHook := []byte("#!/bin/sh\n# my chained pre-commit\nexit 0\n") + if err := os.WriteFile(filepath.Join(hookDir, "pre-commit"), customHook, 0o755); err != nil { + t.Fatalf("seed hook: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(filepath.Join(hookDir, "pre-commit")) + if err != nil { + t.Fatalf("read hook: %v", err) + } + if !bytes.Equal(got, customHook) { + t.Errorf("pre-commit hook was modified without --hook flag") + } +} + +func TestRun_FirstUpgradeDeletesUserFileInsideDocopsDir(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + custom := filepath.Join(dir, "custom.md") + if err := os.WriteFile(custom, []byte("user-added\n"), 0o644); err != nil { + t.Fatalf("seed custom skill: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + if _, err := os.Stat(custom); !os.IsNotExist(err) { + t.Errorf("first-time upgrade should treat custom.md as docops-owned and delete it; stat err=%v", err) + } +} + +func TestRun_DoesNotTouchUserFileOneLevelUp(t *testing.T) { + root := initted(t) + sibling := filepath.Join(root, ".claude/skills/my-stuff.md") + if err := os.WriteFile(sibling, []byte("user content\n"), 0o644); err != nil { + t.Fatalf("seed sibling: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + body, err := os.ReadFile(sibling) + if err != nil { + t.Fatalf("sibling lost: %v", err) + } + if string(body) != "user content\n" { + t.Errorf("sibling content was modified: %q", body) + } +} + +func TestRun_SecondUpgradeRefusesUserAddedFileInDocopsDir(t *testing.T) { + root := initted(t) + // First upgrade — establishes the manifest. + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("first Run: %v", err) + } + + // User now drops a custom skill inside the docops-owned dir. + custom := filepath.Join(root, ".claude/skills/docops/custom.md") + if err := os.WriteFile(custom, []byte("user-added post-init\n"), 0o644); err != nil { + t.Fatalf("seed custom: %v", err) + } + + _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + var unk *ErrUnknownFiles + if !errors.As(err, &unk) { + t.Fatalf("err = %v; want *ErrUnknownFiles", err) + } + want := []string{"custom.md"} + got := append([]string{}, unk.Files...) + sort.Strings(got) + if len(got) != 1 || got[0] != want[0] { + t.Errorf("unknown files = %v; want %v", got, want) + } + + // Custom file should still be present (we refused without writing). + if _, err := os.Stat(custom); err != nil { + t.Errorf("safety belt should not have deleted user file: %v", err) + } +} + +func TestRun_DryRunWritesNothing(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + stalePath := filepath.Join(dir, "next.md") + if err := os.WriteFile(stalePath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}); err != nil { + t.Fatalf("dry-run: %v", err) + } + + if _, err := os.Stat(stalePath); err != nil { + t.Errorf("dry-run should not have removed %s; err=%v", stalePath, err) + } +} + +func TestRun_RefreshesAGENTSBlockInPlace(t *testing.T) { + root := initted(t) + agentsPath := filepath.Join(root, "AGENTS.md") + prefix := "# Project header — do not delete\n\n## Hand-written notes\n\nPreserve me.\n\n" + tmpl, _ := templates.AgentsBlock() + // Write a file with user content + a stale block. + mixed := []byte(prefix + scaffold.BlockStart + "\nstale block content\n" + scaffold.BlockEnd + "\n\n## footer\n") + if err := os.WriteFile(agentsPath, mixed, 0o644); err != nil { + t.Fatalf("seed AGENTS: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatalf("read AGENTS: %v", err) + } + s := string(got) + if !strings.Contains(s, "Hand-written notes") || !strings.Contains(s, "footer") { + t.Errorf("user content lost: %s", s) + } + if strings.Contains(s, "stale block content") { + t.Errorf("stale block content survived refresh") + } + expected := scaffold.ExtractBlock(tmpl) + if !strings.Contains(s, expected) { + t.Errorf("refreshed block does not match template") + } +} diff --git a/templates/AGENTS.md.tmpl b/templates/AGENTS.md.tmpl index 6e0cb8e..2ae407a 100644 --- a/templates/AGENTS.md.tmpl +++ b/templates/AGENTS.md.tmpl @@ -37,6 +37,8 @@ Windsurf, Zed, etc.), read this file before doing any work. ``` docops init # scaffold DocOps in this repo +docops upgrade # refresh DocOps-owned scaffolding after a binary upgrade +docops update-check # check upstream for a newer docops release (cached probe) docops validate # schema + graph invariants docops index # rebuild docs/.index.json docops state # regenerate docs/STATE.md diff --git a/templates/skills/docops/upgrade.md b/templates/skills/docops/upgrade.md new file mode 100644 index 0000000..dcb528f --- /dev/null +++ b/templates/skills/docops/upgrade.md @@ -0,0 +1,51 @@ +--- +name: upgrade +description: Refresh DocOps-owned scaffolding (skills, schemas, AGENTS.md block) in an existing project after `brew upgrade docops` (or equivalent). Preserves docops.yaml and the pre-commit hook by default. +--- + +# /docops:upgrade + +Run after the docops binary itself has been upgraded — it pulls the new +binary's shipped templates into the current project without clobbering +config. + +``` +docops upgrade +``` + +Touches only DocOps-owned scaffolding: + +- `.claude/skills/docops/*.md` and `.cursor/commands/docops/*.md` — synced to the shipped bundle (creates new files, refreshes changed ones, removes files that left the bundle). +- `docs/.docops/schema/*.schema.json` — regenerated from `docops.yaml`. +- The `` block inside `AGENTS.md` — refreshed in place; content outside the markers is preserved. + +Leaves alone by default: + +- `docops.yaml` +- `.git/hooks/pre-commit` +- `docs/{context,decisions,tasks}/*.md` +- `docs/.index.json`, `docs/STATE.md`, `docs/.docops/counters.json` + +Preview before writing: + +``` +docops upgrade --dry-run +``` + +Opt in to overwriting customizable bits: + +``` +docops upgrade --config # also rewrite docops.yaml from the shipped template +docops upgrade --hook # also reinstall .git/hooks/pre-commit +``` + +For CI or scripted use, skip the prompt and emit structured output: + +``` +docops upgrade --yes +docops upgrade --json +``` + +Exit codes: `0` on success or user abort; `2` when there is no +`docops.yaml` (run `docops init` first) or when the `.claude/skills/docops/` +directory contains user-added files docops did not write. diff --git a/templates/skills_lint_test.go b/templates/skills_lint_test.go index 9acaf64..b91cb18 100644 --- a/templates/skills_lint_test.go +++ b/templates/skills_lint_test.go @@ -29,6 +29,7 @@ var knownSubcmds = map[string]bool{ "schema": true, "refresh": true, "update-check": true, + "upgrade": true, } // knownNewKinds are valid arguments for `docops new`. @@ -66,6 +67,18 @@ var flagAllowlist = map[string]map[string]bool{ "refresh": { "--json": true, }, + "upgrade": { + "--dry-run": true, + "--config": true, + "--hook": true, + "--yes": true, + "--json": true, + }, + "update-check": { + "--force": true, + "--snooze": true, + "--json": true, + }, // new ctx / new adr / new task share the "new" key; we resolve // per-kind flags under "new/" and fall back to "new". "new": { From a131d958ec140bb78508c2e8206cf4dd83e7b2ab Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 09:02:42 +0530 Subject: [PATCH 07/11] =?UTF-8?q?planning:=20ADR-0024=20+=20TP-022=20?= =?UTF-8?q?=E2=80=94=20ship=20CLAUDE.md=20alongside=20AGENTS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the decision to write both CLAUDE.md and AGENTS.md from docops init/upgrade, sharing the same docops-managed block. Closes the gap where Claude Code reads CLAUDE.md by default and never sees the docops invariants. Smaller change than gstack's host-rewrite approach; no symlink portability risk. Co-Authored-By: Claude Sonnet 4.6 --- docs/.docops/counters.json | 4 +- docs/.index.json | 62 ++++++++++++-- docs/STATE.md | 8 +- ...e-md-alongside-agents-md-both-share-the.md | 85 +++++++++++++++++++ ...e-md-alongside-agents-md-in-init-and-up.md | 84 ++++++++++++++++++ 5 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md create mode 100644 docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md diff --git a/docs/.docops/counters.json b/docs/.docops/counters.json index 53b1233..21659b4 100644 --- a/docs/.docops/counters.json +++ b/docs/.docops/counters.json @@ -1,8 +1,8 @@ { "version": 1, "next": { - "ADR": 24, + "ADR": 25, "CTX": 5, - "TP": 22 + "TP": 23 } } diff --git a/docs/.index.json b/docs/.index.json index 54d6618..a3412a0 100644 --- a/docs/.index.json +++ b/docs/.index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-22T19:47:51Z", + "generated_at": "2026-04-23T03:32:23Z", "version": 1, "docs": [ { @@ -939,6 +939,10 @@ "id": "ADR-0023", "edge": "related" }, + { + "id": "ADR-0024", + "edge": "related" + }, { "id": "TP-018", "edge": "requires" @@ -998,9 +1002,13 @@ ], "summary": "DocOps ships through Homebrew, Scoop, and direct binary downloads. Once a user runs `docops init`, their project files (skills, schemas, AGENTS.md block) drift from upstream every time we cut a releas…", "word_count": 909, - "last_touched": "2026-04-22T19:45:27Z", + "last_touched": "2026-04-22T19:48:15Z", "age_days": 0, "referenced_by": [ + { + "id": "ADR-0024", + "edge": "related" + }, { "id": "TP-018", "edge": "requires" @@ -1017,6 +1025,32 @@ "implementation": "not-started", "stale": false }, + { + "id": "ADR-0024", + "kind": "ADR", + "folder": "docs/decisions", + "path": "docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md", + "title": "Ship CLAUDE.md alongside AGENTS.md — both share the docops block", + "status": "draft", + "coverage": "required", + "date": "2026-04-23", + "related": [ + "ADR-0021", + "ADR-0023" + ], + "summary": "DocOps writes its invariants (citation rules, no-edit-STATE.md, schema locations, etc.) into a delimited block in `AGENTS.md` so any coding agent — Claude Code, Cursor, Codex, Aider, Copilot, Windsurf…", + "word_count": 555, + "last_touched": "2026-04-23T03:32:01Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-022", + "edge": "requires" + } + ], + "implementation": "not-started", + "stale": false + }, { "id": "CTX-001", "kind": "CTX", @@ -1662,7 +1696,7 @@ ], "summary": "Ship the `docops upgrade` subcommand that pulls the current binary's shipped templates into an already-initialized project without clobbering `docops.yaml` or the pre-commit hook, per ADR-0021.", "word_count": 743, - "last_touched": "2026-04-22T18:02:14Z", + "last_touched": "2026-04-22T19:48:15Z", "age_days": 0, "referenced_by": [ { @@ -1712,7 +1746,7 @@ ], "summary": "Lift the helpers that `docops upgrade` will need to share with `docops init` out of `internal/initter/` and into a new `internal/scaffold/` package, with **zero behavior change**. Sets up TP-018 to la…", "word_count": 370, - "last_touched": "2026-04-22T19:47:02Z", + "last_touched": "2026-04-22T19:48:15Z", "age_days": 0, "referenced_by": [ { @@ -1739,7 +1773,7 @@ ], "summary": "Ship the update-check mechanism specified by ADR-0023: a small internal package, a standalone `docops update-check` subcommand, and integration into `docops upgrade` so users learn when their binary i…", "word_count": 647, - "last_touched": "2026-04-22T19:47:37Z", + "last_touched": "2026-04-22T19:48:15Z", "age_days": 0, "referenced_by": [ { @@ -1751,6 +1785,24 @@ "TP-018" ], "stale": false + }, + { + "id": "TP-022", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md", + "title": "Write CLAUDE.md alongside AGENTS.md in init and upgrade", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0024" + ], + "summary": "Implement ADR-0024: docops writes both `CLAUDE.md` and `AGENTS.md` during `init` and refreshes both during `upgrade`. Both files share the same `\u003c!-- docops:start --\u003e … \u003c!-- docops:end --\u003e` block.", + "word_count": 395, + "last_touched": "2026-04-23T03:32:20Z", + "age_days": 0, + "stale": false } ] } diff --git a/docs/STATE.md b/docs/STATE.md index 8dfc981..23ffd5e 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,12 +1,12 @@ -# Project State — 2026-04-22 +# Project State — 2026-04-23 ## Counts - Context: 4 active · 0 superseded -- ADRs: 22 accepted · 1 draft · 0 superseded (22 `coverage: required`, 1 `coverage: not-needed`) -- Tasks: 4 backlog · 0 active · 0 blocked · 17 done +- ADRs: 22 accepted · 2 draft · 0 superseded (23 `coverage: required`, 1 `coverage: not-needed`) +- Tasks: 5 backlog · 0 active · 0 blocked · 17 done ## Needs attention @@ -18,6 +18,7 @@ ## Recent activity +- 2026-04-23 7656d08 planning: ADR-0023 + TP-020/TP-021 — split TP-018 into refactor + upgrader + update-check - 2026-04-23 6f41758 TP-012: docops get/list/graph/next — focused read commands - 2026-04-23 32cc60b TP-011: docops search — substring/regex content search - 2026-04-23 1aa3e32 remove internal TP-xxx IDs from help output; fix AGENTS.md to not document unbuilt commands @@ -37,5 +38,4 @@ - 2026-04-22 5255483 planning: search + CLI-as-query-layer ADRs plus two tasks - 2026-04-22 50eeb7a initial docs & idea - 2026-04-22 4631e43 TP-017: docops new --body + validator enum hints -- 2026-04-22 2b1a7ee planning: ADR-0021 + TP-018 — docops upgrade for in-place bumps diff --git a/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md b/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md new file mode 100644 index 0000000..19819d4 --- /dev/null +++ b/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md @@ -0,0 +1,85 @@ +--- +title: Ship CLAUDE.md alongside AGENTS.md — both share the docops block +status: draft +coverage: required +date: "2026-04-23" +supersedes: [] +related: [ADR-0021, ADR-0023] +tags: [] +--- + +## Context + +DocOps writes its invariants (citation rules, no-edit-STATE.md, schema +locations, etc.) into a delimited block in `AGENTS.md` so any coding +agent — Claude Code, Cursor, Codex, Aider, Copilot, Windsurf, Zed — +finds them on first contact. AGENTS.md was chosen as the multi-tool +standard surface. + +The gap: **Claude Code reads `CLAUDE.md` by default, not `AGENTS.md`.** +A user who runs `docops init` and then opens the project in Claude +Code never sees the docops invariants until they manually point Claude +at AGENTS.md. The invariants are then routinely violated (tasks +without citations, edits to STATE.md, etc.) and the user blames docops. + +`gstack` solves this by treating `CLAUDE.md` as the canonical +single-source and rendering host-specific files (AGENTS.md for +Hermes, etc.) via a path/tool rewrite map. That works because gstack +ships per-host packaging anyway. DocOps does not have host packaging +and does not want to grow one for this single use case. + +## Decision + +DocOps writes **both** `CLAUDE.md` and `AGENTS.md` at the project +root. Both contain the same `` +block with the same docops invariants. The non-block preamble may +differ (CLAUDE.md gets a one-line note that AGENTS.md is the +multi-tool sibling; otherwise content is identical). + +The block-merge logic that already powers `init` and `upgrade` (see +ADR-0021, `internal/scaffold.MergeAgentsBlock`) is reused verbatim +for CLAUDE.md. The same three cases apply: + +- File absent → write the full template. +- File present without block → append the block, preserve user content. +- File present with block → refresh just the block, preserve everything outside the markers. + +## Rationale + +- **Smallest delta over current behavior.** No template engine, no + host registry, no per-host rendering — just a second template and + a second call to the existing planner. +- **Robust against Claude Code defaults.** The user no longer has + to know to redirect Claude at AGENTS.md. +- **Multi-tool ecosystem unaffected.** AGENTS.md keeps shipping; tools + that read it (Cursor, Aider, Codex) see no change. +- **Symlink rejected.** A CLAUDE.md → AGENTS.md symlink would skirt + the duplication problem but breaks on Windows without dev mode and + has poor git portability across shell environments. +- **Flipping canonical (gstack-style) rejected.** Making CLAUDE.md + primary and AGENTS.md derived would force a host registry and a + template-rewrite engine into docops. Out of scope for the value. +- **Block duplication is bounded.** The docops block is ~80 lines. + Both files refresh in one upgrade pass; nothing drifts independently + because the source of truth is the embedded template, not either + file. + +## Consequences + +- `docops init` now creates two files where it used to create one. + Existing v0.1.x projects that already have AGENTS.md but not + CLAUDE.md get CLAUDE.md added on next `docops upgrade` (the planner + treats "absent" as "create"; no flag required). +- Users who hand-curate CLAUDE.md keep their content — only the + delimited block is owned by docops. +- The `templates/AGENTS.md.tmpl` and `templates/CLAUDE.md.tmpl` files + must stay in sync for the docops block. A small templates-package + test asserts that `ExtractBlock` returns identical content from + both, so future drift is caught at build time. +- The "Notes for humans" footer on each file may eventually diverge + per audience. ADR remains valid; only the block contract is + load-bearing. +- A future ADR may extend the same pattern to `.cursorrules`, + `.aider.conf.yml`, or other tool-specific surfaces. Each would be + evaluated on its own ROI; this ADR commits only to AGENTS.md + + CLAUDE.md. diff --git a/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md b/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md new file mode 100644 index 0000000..9fc0d6a --- /dev/null +++ b/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md @@ -0,0 +1,84 @@ +--- +title: Write CLAUDE.md alongside AGENTS.md in init and upgrade +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0024] +depends_on: [] +--- + +## Goal + +Implement ADR-0024: docops writes both `CLAUDE.md` and `AGENTS.md` +during `init` and refreshes both during `upgrade`. Both files share +the same `` block. + +## Acceptance + +### Templates + +- New `templates/CLAUDE.md.tmpl` — same docops block as + `AGENTS.md.tmpl`, with a one-line non-block preamble that mentions + `AGENTS.md` is the multi-tool sibling. +- New `templates.ClaudeBlock()` accessor in `templates/templates.go` + that returns the embedded `CLAUDE.md.tmpl` bytes (mirrors + `templates.AgentsBlock()`). +- New test `templates/agents_claude_block_sync_test.go`: asserts that + `scaffold.ExtractBlock(AgentsBlock())` equals + `scaffold.ExtractBlock(ClaudeBlock())` byte-for-byte. Catches drift + the moment a templates author edits one without the other. + +### initter + +- `internal/initter/initter.go` `plan()` calls a generalized + `planMarkdownBlock(opts, "CLAUDE.md", claudeTmpl)` and appends both + the AGENTS.md and CLAUDE.md actions. Existing `planAgents` becomes + the type-generic helper or is renamed; behavior for AGENTS.md is + unchanged. +- The init announcement block in `cmd/docops/cmd_init.go` mentions + CLAUDE.md alongside AGENTS.md. + +### upgrader + +- `internal/upgrader/upgrader.go` `plan()` emits a refresh action for + both `CLAUDE.md` and `AGENTS.md` when each file is present (or + creates either if absent — the planner already handles missing + files via the same merge logic). +- The upgrade announcement block in `cmd/docops/cmd_upgrade.go` + mentions both files. + +### Tests + +- `internal/initter/initter_test.go` adds a CLAUDE.md case mirroring + the existing AGENTS.md merge test. +- `internal/upgrader/upgrader_test.go` adds: + - A case where both files exist with stale blocks → both refresh. + - A case where AGENTS.md exists with the block but CLAUDE.md is + absent → CLAUDE.md is created with the full template. + - A case where the user has hand-written content in CLAUDE.md + outside the block → preserved across upgrade. + +### Docs and fallout + +- `templates/AGENTS.md.tmpl` "Notes for humans" footer references + CLAUDE.md so users learn both files are managed. +- `templates/skills/docops/init.md` and + `templates/skills/docops/upgrade.md` mention CLAUDE.md. +- README "Quickstart" briefly notes that init scaffolds both files. +- AGENTS.md.tmpl mention of `docops upgrade` already covers the + upgrade path — no new line needed. + +## Notes + +The block-merge logic in `internal/scaffold.MergeAgentsBlock` is +already file-agnostic — it operates on `existing []byte` and returns +merged bytes. No changes needed there. The "AGENTS.md" name is +plumbed only at the planner-call layer, so generalization is small. + +For the docops repo itself: after this ships, run `docops upgrade +--yes` once to scaffold the missing CLAUDE.md (the repo currently +has only AGENTS.md). That can be a separate dogfood commit. + +Out of scope: `.cursorrules`, `.aider.conf.yml`, host packaging, +template rewriting. ADR-0024 explicitly limits scope to the two +markdown files. A future ADR can expand if needed. From 4b0f382143ffb99dceab6d5d32ddf1bfa0513018 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 09:10:24 +0530 Subject: [PATCH 08/11] TP-022: ship CLAUDE.md alongside AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files now carry the same docops block so Claude Code (which reads CLAUDE.md by default) and other agents (which read AGENTS.md) see the same project-state invariants. init writes both; upgrade refreshes both and creates either one if absent — so v0.1.x users gain CLAUDE.md on first post-ADR-0024 upgrade. Existing planAgents helpers in initter and upgrader are generalized to planMarkdownBlock(rel, tmpl) and called twice (once per file). A new templates-package test asserts byte-identical docops blocks between AGENTS.md.tmpl and CLAUDE.md.tmpl so future template authors catch drift at build time. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- cmd/docops/cmd_init.go | 2 +- cmd/docops/cmd_upgrade.go | 2 +- internal/initter/initter.go | 25 ++++-- internal/initter/initter_test.go | 46 +++++++++++ internal/upgrader/upgrader.go | 50 +++++++---- internal/upgrader/upgrader_test.go | 75 +++++++++++++++++ templates/AGENTS.md.tmpl | 6 +- templates/CLAUDE.md.tmpl | 96 ++++++++++++++++++++++ templates/agents_claude_block_sync_test.go | 53 ++++++++++++ templates/skills/docops/init.md | 4 +- templates/skills/docops/upgrade.md | 2 +- templates/templates.go | 10 ++- 13 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 templates/CLAUDE.md.tmpl create mode 100644 templates/agents_claude_block_sync_test.go diff --git a/README.md b/README.md index 3faef56..004357a 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ docops --help From the root of any git repo (empty or existing): ```sh -docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook +docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook, AGENTS.md + CLAUDE.md docops new ctx "Vision" --type brief --no-open # first CTX docops new adr "Pick a database" # first decision docops new task "Wire up SQLite" --requires ADR-0001 diff --git a/cmd/docops/cmd_init.go b/cmd/docops/cmd_init.go index ebd64f3..599047c 100644 --- a/cmd/docops/cmd_init.go +++ b/cmd/docops/cmd_init.go @@ -81,7 +81,7 @@ func cmdInit(args []string) int { fmt.Fprintln(os.Stdout, " - docs/{context,decisions,tasks} folders") fmt.Fprintln(os.Stdout, " - docops.yaml at the repo root") fmt.Fprintln(os.Stdout, " - JSON Schemas for editor validation") - fmt.Fprintln(os.Stdout, " - An AGENTS.md block (merges into existing content if present)") + fmt.Fprintln(os.Stdout, " - AGENTS.md and CLAUDE.md docops blocks (merges into existing content if present)") fmt.Fprintln(os.Stdout, " - A .git/hooks/pre-commit hook (if .git exists)") fmt.Fprintln(os.Stdout, " - /docops:* agent-skill scaffolds under .claude/ and .cursor/") fmt.Fprintln(os.Stdout, "Safe to re-run; existing files are never silently overwritten.") diff --git a/cmd/docops/cmd_upgrade.go b/cmd/docops/cmd_upgrade.go index aa89e8e..0084c00 100644 --- a/cmd/docops/cmd_upgrade.go +++ b/cmd/docops/cmd_upgrade.go @@ -143,7 +143,7 @@ func printUpgradeHeader(w io.Writer, cwd string, optConfig, optHook bool) { fmt.Fprintf(w, "docops upgrade will refresh DocOps-owned scaffolding in %s:\n", cwd) fmt.Fprintln(w, " - .claude/skills/docops/* and .cursor/commands/docops/* (replaced/removed to match the shipped bundle)") fmt.Fprintln(w, " - docs/.docops/schema/*.schema.json (regenerated from docops.yaml)") - fmt.Fprintln(w, " - The block in AGENTS.md (refreshed in place)") + fmt.Fprintln(w, " - The block in AGENTS.md and CLAUDE.md (refreshed in place; either file is created if absent)") if optConfig { fmt.Fprintln(w, " - docops.yaml (overwritten — --config)") } diff --git a/internal/initter/initter.go b/internal/initter/initter.go index ad02655..90083d2 100644 --- a/internal/initter/initter.go +++ b/internal/initter/initter.go @@ -117,17 +117,29 @@ func plan(opts Options) ([]Action, error) { actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, opts.Force)) } - // 4. AGENTS.md — delimited-block merge if a user file exists. + // 4. AGENTS.md and CLAUDE.md — delimited-block merge if a user + // file exists, otherwise write the template verbatim. Both files + // are docops-managed and share the same docops block (ADR-0024). agentsTmpl, err := templates.AgentsBlock() if err != nil { return nil, fmt.Errorf("read agents template: %w", err) } - agentsAction, err := planAgents(opts, agentsTmpl) + agentsAction, err := planMarkdownBlock(opts, "AGENTS.md", agentsTmpl) if err != nil { return nil, err } actions = append(actions, agentsAction) + claudeTmpl, err := templates.ClaudeBlock() + if err != nil { + return nil, fmt.Errorf("read claude template: %w", err) + } + claudeAction, err := planMarkdownBlock(opts, "CLAUDE.md", claudeTmpl) + if err != nil { + return nil, err + } + actions = append(actions, claudeAction) + // 5. Pre-commit hook. hook, err := templates.PreCommitHook() if err != nil { @@ -163,11 +175,12 @@ func plan(opts Options) ([]Action, error) { return actions, nil } -// planAgents decides how to render AGENTS.md. If the file is absent we -// write the template verbatim. If it exists with a block, we replace +// planMarkdownBlock decides how to render a docops-managed markdown +// file (AGENTS.md, CLAUDE.md). If the file is absent we write the +// template verbatim. If it exists with a docops block, we refresh // just the block. If it exists without a block, we append the block. -func planAgents(opts Options, tmpl []byte) (Action, error) { - rel := "AGENTS.md" +// Used by both init and (indirectly) upgrade via the same logic. +func planMarkdownBlock(opts Options, rel string, tmpl []byte) (Action, error) { abs := filepath.Join(opts.Root, rel) existing, err := os.ReadFile(abs) diff --git a/internal/initter/initter_test.go b/internal/initter/initter_test.go index 72d7b88..44385d3 100644 --- a/internal/initter/initter_test.go +++ b/internal/initter/initter_test.go @@ -206,6 +206,52 @@ func TestRun_AgentsBlockRefresh_ReplacesBlockOnly(t *testing.T) { } } +func TestRun_CreatesClaudeMdAlongsideAgentsMd(t *testing.T) { + root := t.TempDir() + withGit(t, root) + + if _, err := Run(Options{Root: root, Out: io.Discard}); err != nil { + t.Fatalf("run: %v", err) + } + + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s missing after init: %v", name, err) + } + s := string(body) + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("%s missing docops block markers", name) + } + } +} + +func TestRun_ClaudeMdMerge_PreservesUserContent(t *testing.T) { + root := t.TempDir() + withGit(t, root) + + userBody := "# Custom CLAUDE.md\n\nThis project's specific Claude tweaks.\n" + if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte(userBody), 0o644); err != nil { + t.Fatalf("write user CLAUDE.md: %v", err) + } + + if _, err := Run(Options{Root: root, Out: io.Discard}); err != nil { + t.Fatalf("run: %v", err) + } + + merged, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("read merged: %v", err) + } + s := string(merged) + if !strings.Contains(s, "This project's specific Claude tweaks.") { + t.Errorf("user content lost: %s", s) + } + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("docops block missing after merge: %s", s) + } +} + func TestRun_NoGitDir_SkipsHook(t *testing.T) { root := t.TempDir() // no withGit() diff --git a/internal/upgrader/upgrader.go b/internal/upgrader/upgrader.go index e5e68f6..42388ea 100644 --- a/internal/upgrader/upgrader.go +++ b/internal/upgrader/upgrader.go @@ -174,18 +174,30 @@ func plan(opts Options) ([]scaffold.Action, error) { actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, true)) } - // 2. AGENTS.md block refresh (only if file exists). + // 2. AGENTS.md and CLAUDE.md block refresh. Both files share the + // same docops block (ADR-0024). For each: if the file exists, the + // block is merged in place; if absent, the full template is + // written so v0.1.x users gain CLAUDE.md on first upgrade after + // this lands. agentsTmpl, err := templates.AgentsBlock() if err != nil { return nil, fmt.Errorf("read agents template: %w", err) } - agentsAction, err := planAgents(opts, agentsTmpl) + agentsAction, err := planMarkdownBlock(opts.Root, "AGENTS.md", agentsTmpl) if err != nil { return nil, err } - if agentsAction != nil { - actions = append(actions, *agentsAction) + actions = append(actions, agentsAction) + + claudeTmpl, err := templates.ClaudeBlock() + if err != nil { + return nil, fmt.Errorf("read claude template: %w", err) + } + claudeAction, err := planMarkdownBlock(opts.Root, "CLAUDE.md", claudeTmpl) + if err != nil { + return nil, err } + actions = append(actions, claudeAction) // 3. Skills — sync each docops-owned dir against the shipped bundle. skills, err := scaffold.LoadShippedSkills() @@ -231,25 +243,33 @@ func plan(opts Options) ([]scaffold.Action, error) { return actions, nil } -// planAgents returns a refresh action for AGENTS.md if the file -// exists and the docops block is stale or absent. Returns nil if -// AGENTS.md doesn't exist (upgrade does not create it; that's init's -// job). -func planAgents(opts Options, tmpl []byte) (*scaffold.Action, error) { - rel := "AGENTS.md" - abs := filepath.Join(opts.Root, rel) +// planMarkdownBlock returns a refresh-or-create action for a +// docops-managed markdown file (AGENTS.md, CLAUDE.md). When the file +// is absent, it writes the template verbatim — upgrade now creates +// missing managed files so v0.1.x users gain CLAUDE.md on first +// post-ADR-0024 upgrade. When present, the docops block is merged in +// place; the rest of the file is preserved. +func planMarkdownBlock(rootAbs, rel string, tmpl []byte) (scaffold.Action, error) { + abs := filepath.Join(rootAbs, rel) existing, err := os.ReadFile(abs) if err != nil { if os.IsNotExist(err) { - return nil, nil + return scaffold.Action{ + Path: abs, + Rel: rel, + Kind: scaffold.KindWriteFile, + Body: tmpl, + Mode: 0o644, + Reason: "create", + }, nil } - return nil, err + return scaffold.Action{}, err } merged, changed, reason := scaffold.MergeAgentsBlock(existing, tmpl) if !changed { - return &scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindSkip, Reason: reason}, nil + return scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindSkip, Reason: reason}, nil } - return &scaffold.Action{ + return scaffold.Action{ Path: abs, Rel: rel, Kind: scaffold.KindMergeAgents, diff --git a/internal/upgrader/upgrader_test.go b/internal/upgrader/upgrader_test.go index c5cb6e1..f669ed0 100644 --- a/internal/upgrader/upgrader_test.go +++ b/internal/upgrader/upgrader_test.go @@ -304,6 +304,81 @@ func TestRun_SecondUpgradeRefusesUserAddedFileInDocopsDir(t *testing.T) { } } +func TestRun_CreatesClaudeMdWhenAbsent(t *testing.T) { + root := initted(t) + // initted does not seed CLAUDE.md (matches the v0.1.x state pre-ADR-0024). + if _, err := os.Stat(filepath.Join(root, "CLAUDE.md")); !os.IsNotExist(err) { + t.Fatalf("test precondition: CLAUDE.md should be absent, got err=%v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + body, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("CLAUDE.md missing after upgrade: %v", err) + } + s := string(body) + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("CLAUDE.md missing docops block markers: %s", s) + } +} + +func TestRun_RefreshesBothAGENTSAndClaudeBlocks(t *testing.T) { + root := initted(t) + stale := scaffold.BlockStart + "\nold body\n" + scaffold.BlockEnd + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body := "# Header\n\n" + stale + "\n\nuser footer\n" + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + s := string(body) + if strings.Contains(s, "old body") { + t.Errorf("%s: stale block survived refresh", name) + } + if !strings.Contains(s, "user footer") { + t.Errorf("%s: user footer dropped", name) + } + } +} + +func TestRun_PreservesUserContentInClaudeMdAcrossUpgrade(t *testing.T) { + root := initted(t) + // Seed CLAUDE.md with hand-written content outside any docops block. + body := "# My CLAUDE.md\n\nProject-specific guidance for Claude only.\n\nSee AGENTS.md for the multi-tool view.\n" + if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte(body), 0o644); err != nil { + t.Fatalf("seed CLAUDE.md: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + s := string(got) + if !strings.Contains(s, "Project-specific guidance for Claude only.") { + t.Errorf("user CLAUDE.md content lost across upgrade: %s", s) + } + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("docops block missing after upgrade: %s", s) + } +} + func TestRun_DryRunWritesNothing(t *testing.T) { root := initted(t) dir := filepath.Join(root, ".claude/skills/docops") diff --git a/templates/AGENTS.md.tmpl b/templates/AGENTS.md.tmpl index 2ae407a..f21efe9 100644 --- a/templates/AGENTS.md.tmpl +++ b/templates/AGENTS.md.tmpl @@ -87,6 +87,8 @@ JSON Schemas for frontmatter validation live in `docs/.docops/schema/` (`context ## Notes for humans -The block above is auto-maintained by `docops init` and `docops refresh-agents-md`. +The block above is auto-maintained by `docops init` and `docops upgrade`. Edit content outside the `` delimiters freely; DocOps will -preserve it. Edits inside the delimiters will be overwritten. +preserve it. Edits inside the delimiters will be overwritten on the next +upgrade. The same docops block also appears in `CLAUDE.md` (which Claude +Code reads by default); both files refresh from the same shipped template. diff --git a/templates/CLAUDE.md.tmpl b/templates/CLAUDE.md.tmpl new file mode 100644 index 0000000..829d04a --- /dev/null +++ b/templates/CLAUDE.md.tmpl @@ -0,0 +1,96 @@ +# Claude in this repo + +This repository is managed with **DocOps** — a typed project-state substrate. +You're reading this because Claude Code loads CLAUDE.md by default. The same +docops block is mirrored in `AGENTS.md` for non-Claude agents (Cursor, Aider, +Codex, Copilot, Windsurf, Zed); both files are auto-maintained by +`docops upgrade`. + + + +## Orientation + +- **Why we're building this:** `docs/context/CTX-*` (look for documents of `type: brief` or `type: prd`). +- **What's been decided:** `docs/decisions/ADR-*.md`. Frontmatter is load-bearing — read it. +- **What to do next:** `docs/STATE.md` for counts, needs-attention, and active work. +- **Hard guardrails:** any CTX of `type: memo` or `type: notes` that describes constraints. + +## Folder layout + +| Folder | Contents | +|---|---| +| `docs/context/` | Stakeholder inputs: PRDs, design docs, memos, research, notes. File prefix `CTX-`. | +| `docs/decisions/` | ADRs. File prefix `ADR-`. | +| `docs/tasks/` | Work units. File prefix `TP-`. Every task cites ≥1 ADR or CTX. | +| `docs/STATE.md` | Auto-generated state snapshot. Do not edit by hand. | +| `docs/.index.json` | Auto-generated graph with computed reverse edges. Do not cat it into your context — use `docops state` or read individual doc files instead. | + +## Invariants you MUST respect + +1. **Every task must cite at least one ADR or CTX** in its `requires:` frontmatter. The validator refuses tasks without it. +2. **References must resolve.** No citing non-existent docs; no citing superseded docs without good reason. +3. **Do not edit `docs/STATE.md` or `docs/.index.json`** — both are regenerated from source. +4. **Do not edit reverse-edge fields** in source frontmatter. They are computed in the index. +5. **Filename is the ID.** `ADR-0020-whatever.md` is `ADR-0020`. Don't add an `id:` field. + +## CLI commands — this is the query API + +### Shipped (all support `--json` for structured output) + +``` +docops init # scaffold DocOps in this repo +docops upgrade # refresh DocOps-owned scaffolding after a binary upgrade +docops update-check # check upstream for a newer docops release (cached probe) +docops validate # schema + graph invariants +docops index # rebuild docs/.index.json +docops state # regenerate docs/STATE.md +docops audit # structural coverage gaps +docops refresh # validate + index + state in one pass +docops new ctx "title" --type memo +docops new adr "title" [--related ADR-xxxx] +docops new task "title" --requires ADR-xxxx,CTX-xxx +docops schema # (re)write schema JSON from docops.yaml +``` + +### Not yet built — do not call these + +`status`, `get`, `list`, `graph`, `next`, `search`, `review` do not exist +yet. If your workflow needs one, propose a task first rather than inventing +flags or behaviour. + +**For now, instead of `list`/`get`/`search`:** read `docs/STATE.md` for +counts and needs-attention, or read individual `docs/{context,decisions,tasks}/` +files directly. Avoid loading `docs/.index.json` wholesale — it is for +bootstrap / CI consumers, not routine agent queries. See +`docs/decisions/ADR-0018-cli-as-query-layer.md` for the rationale. + +The binary is language-agnostic — install via direct download, Homebrew, Scoop, or Docker. No Node/Bun/Python dependency. + +## Recommended agent workflow + +1. Read `docs/STATE.md`. +2. Run `docops audit` to see open gaps. +3. Read `docs/tasks/` to pick a task (check `depends_on` before starting). +4. Before coding: read every doc in the task's `requires:` and `depends_on:`. +5. Work in your native plan/execute mode. DocOps does not prescribe how you code. +6. After finishing, update the task's frontmatter `status:` field and run `docops refresh` to regenerate index and STATE.md. +7. If your work revealed a new decision, `docops new adr`. If a new gap, `docops new task` with citations. + +## Editor integration + +JSON Schemas for frontmatter validation live in `docs/.docops/schema/` (`context.schema.json`, `decision.schema.json`, `task.schema.json`). Wire them up in VS Code via the `redhat.vscode-yaml` extension and run `docops schema` to regenerate after editing `context_types:` in `docops.yaml`. + +## Pairs well with + +- **GStack** role skills — they apply perspective; DocOps provides the typed state they read. +- **Native plan mode** of your IDE — DocOps explicitly does not try to replace it. + + + +## Notes for humans + +The block above is auto-maintained by `docops init` and `docops upgrade`. +Edit content outside the `` delimiters freely; DocOps will +preserve it. Edits inside the delimiters will be overwritten on the next +upgrade. The same docops block also appears in `AGENTS.md` (the multi-tool +sibling); both refresh from the same shipped template. diff --git a/templates/agents_claude_block_sync_test.go b/templates/agents_claude_block_sync_test.go new file mode 100644 index 0000000..0c56799 --- /dev/null +++ b/templates/agents_claude_block_sync_test.go @@ -0,0 +1,53 @@ +package templates + +import ( + "strings" + "testing" +) + +// extractDocopsBlock returns the content between the docops markers +// in the given template body. Inlined here to avoid importing +// internal/scaffold (which would create a templates → scaffold edge +// that doesn't exist anywhere else; templates is the leafmost package). +func extractDocopsBlock(body []byte) string { + const start = "" + const end = "" + s := string(body) + i := strings.Index(s, start) + if i < 0 { + return "" + } + i += len(start) + j := strings.Index(s, end) + if j < 0 || j < i { + return "" + } + return s[i:j] +} + +// TestAgentsClaudeBlocksInSync enforces ADR-0024: the docops block in +// AGENTS.md.tmpl and CLAUDE.md.tmpl must be byte-identical so users +// see the same invariants regardless of which file their tool reads. +// Drift in either template fails this test at build time. +func TestAgentsClaudeBlocksInSync(t *testing.T) { + agents, err := AgentsBlock() + if err != nil { + t.Fatalf("AgentsBlock: %v", err) + } + claude, err := ClaudeBlock() + if err != nil { + t.Fatalf("ClaudeBlock: %v", err) + } + + a := extractDocopsBlock(agents) + c := extractDocopsBlock(claude) + if a == "" { + t.Fatal("AGENTS.md.tmpl is missing the docops block markers") + } + if c == "" { + t.Fatal("CLAUDE.md.tmpl is missing the docops block markers") + } + if a != c { + t.Errorf("docops block in AGENTS.md.tmpl and CLAUDE.md.tmpl have drifted.\n--- AGENTS ---\n%s\n--- CLAUDE ---\n%s", a, c) + } +} diff --git a/templates/skills/docops/init.md b/templates/skills/docops/init.md index 091da61..dfa9518 100644 --- a/templates/skills/docops/init.md +++ b/templates/skills/docops/init.md @@ -1,6 +1,6 @@ --- name: init -description: Scaffold DocOps into a bare repository — creates docs/ folders, docops.yaml, JSON Schemas, an AGENTS.md block, and a pre-commit hook. Safe to run twice; use --dry-run to preview. +description: Scaffold DocOps into a bare repository — creates docs/ folders, docops.yaml, JSON Schemas, AGENTS.md and CLAUDE.md blocks, and a pre-commit hook. Safe to run twice; use --dry-run to preview. --- # /docops:init @@ -18,7 +18,7 @@ What it does: - Creates `docs/context/`, `docs/decisions/`, `docs/tasks/` if absent. - Writes `docops.yaml` at the repo root with sensible defaults. - Writes JSON Schemas to `docs/.docops/schema/` for in-editor validation. -- Writes or refreshes the `` block inside `AGENTS.md`. +- Writes or refreshes the `` block inside `AGENTS.md` and `CLAUDE.md` (both files share the same docops block; Claude Code reads CLAUDE.md by default while other agents read AGENTS.md). - Installs a language-agnostic pre-commit hook that runs `docops validate`. - Scaffolds `/docops:*` skills into `.claude/skills/docops/` and `.cursor/commands/docops/`. diff --git a/templates/skills/docops/upgrade.md b/templates/skills/docops/upgrade.md index dcb528f..e75cda9 100644 --- a/templates/skills/docops/upgrade.md +++ b/templates/skills/docops/upgrade.md @@ -17,7 +17,7 @@ Touches only DocOps-owned scaffolding: - `.claude/skills/docops/*.md` and `.cursor/commands/docops/*.md` — synced to the shipped bundle (creates new files, refreshes changed ones, removes files that left the bundle). - `docs/.docops/schema/*.schema.json` — regenerated from `docops.yaml`. -- The `` block inside `AGENTS.md` — refreshed in place; content outside the markers is preserved. +- The `` block inside `AGENTS.md` and `CLAUDE.md` — refreshed in place; content outside the markers is preserved. Either file is created if absent (so v0.1.x users gain CLAUDE.md on first upgrade). Leaves alone by default: diff --git a/templates/templates.go b/templates/templates.go index d9e7c87..dd8e10c 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -13,7 +13,7 @@ import ( "strings" ) -//go:embed AGENTS.md.tmpl docops.yaml.tmpl hooks/pre-commit skills/docops +//go:embed AGENTS.md.tmpl CLAUDE.md.tmpl docops.yaml.tmpl hooks/pre-commit skills/docops var tree embed.FS // AgentsBlock returns the body of templates/AGENTS.md.tmpl. @@ -21,6 +21,14 @@ func AgentsBlock() ([]byte, error) { return tree.ReadFile("AGENTS.md.tmpl") } +// ClaudeBlock returns the body of templates/CLAUDE.md.tmpl. The +// docops block inside (between `` and +// ``) is byte-identical to AgentsBlock; only the +// preamble and footer differ. See ADR-0024. +func ClaudeBlock() ([]byte, error) { + return tree.ReadFile("CLAUDE.md.tmpl") +} + // DocopsYAML returns the body of templates/docops.yaml.tmpl. func DocopsYAML() ([]byte, error) { return tree.ReadFile("docops.yaml.tmpl") From 4012d087057425aaebd343b8c9cc603cba3c658c Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 09:24:08 +0530 Subject: [PATCH 09/11] TP-021 follow-up: automate VERSION bump + tag with make release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'make release VERSION=X.Y.Z' target validates the version, bumps the VERSION file, commits, tags annotated, and pushes both — replacing the manual "edit VERSION, commit, tag, push" sequence with one command. Refuses on a dirty tree, off-main branch, or pre-existing tag. DRY_RUN=1 prints what would happen without writing. Release workflow gains a guard step that fails the run if the pushed tag does not match the VERSION file body. Catches the easy mistake of tagging manually without bumping VERSION (which would silently break docops update-check for users on the previous release, since the file is what raw.githubusercontent.com serves). README "Release" section updated to describe the new flow. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 15 ++++++++++++ Makefile | 44 ++++++++++++++++++++++++++++++++++- README.md | 17 ++++++++++---- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18c331e..8dd8ef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,21 @@ jobs: with: go-version: '1.23' cache: true + + # Catch the easy mistake of pushing a tag without bumping VERSION. + # docops update-check fetches VERSION via raw.githubusercontent.com, + # so a mismatch silently breaks "is upgrade available" for everyone + # who already has the previous release. Fail loud here instead. + - name: Verify VERSION matches tag + run: | + tag_version="${GITHUB_REF#refs/tags/v}" + file_version=$(tr -d '[:space:]' < VERSION) + if [ "$tag_version" != "$file_version" ]; then + echo "::error::tag $GITHUB_REF (=$tag_version) does not match VERSION file (=$file_version)" + echo "::error::run 'make release VERSION=$tag_version' on main to do this correctly" + exit 1 + fi + - uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser diff --git a/Makefile b/Makefile index 3b8967f..cf8a1b7 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/version.Commit=$(COMMIT) \ -X $(PKG)/internal/version.Date=$(DATE) -.PHONY: build install test lint clean tidy release-snapshot +.PHONY: build install test lint clean tidy release-snapshot release build: @mkdir -p bin @@ -31,3 +31,45 @@ clean: release-snapshot: goreleaser release --snapshot --clean + +# make release VERSION=X.Y.Z +# Bumps the VERSION file (read by docops update-check via raw.githubusercontent.com), +# commits the bump, creates an annotated v$VERSION tag, and pushes both. +# The tag push triggers .github/workflows/release.yml → goreleaser builds and +# publishes the GitHub Release. Pass DRY_RUN=1 to print what would happen +# without writing or pushing anything. +release: + @if [ -z "$(VERSION)" ] || [ "$(VERSION)" = "dev" ]; then \ + echo "usage: make release VERSION=X.Y.Z [DRY_RUN=1]"; exit 2; \ + fi + @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || \ + (echo "VERSION must match X.Y.Z (got $(VERSION))" && exit 2) + @if [ -n "$$(git status --porcelain)" ]; then \ + echo "working tree not clean; commit or stash first"; exit 2; \ + fi + @branch=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$branch" != "main" ]; then \ + echo "refusing to release from '$$branch' — switch to main first"; exit 2; \ + fi + @if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \ + echo "tag v$(VERSION) already exists locally"; exit 2; \ + fi + @if git ls-remote --exit-code --tags origin "refs/tags/v$(VERSION)" >/dev/null 2>&1; then \ + echo "tag v$(VERSION) already exists on origin"; exit 2; \ + fi + @if [ -n "$(DRY_RUN)" ]; then \ + echo "[dry-run] would write '$(VERSION)' to VERSION"; \ + echo "[dry-run] would commit: chore: release v$(VERSION)"; \ + echo "[dry-run] would tag: v$(VERSION)"; \ + echo "[dry-run] would push: main + v$(VERSION) to origin"; \ + exit 0; \ + fi + echo "$(VERSION)" > VERSION + git add VERSION + git commit -m "chore: release v$(VERSION)" + git tag -a "v$(VERSION)" -m "v$(VERSION)" + git push origin main + git push origin "v$(VERSION)" + @echo + @echo "v$(VERSION) tagged and pushed. Watch the release workflow:" + @echo " gh run watch" diff --git a/README.md b/README.md index 004357a..1cecd3b 100644 --- a/README.md +++ b/README.md @@ -107,19 +107,28 @@ make lint # go vet ./... ### Release -Tag a commit with `vX.Y.Z`; the `Release` workflow runs goreleaser, which builds the matrix, attaches archives + checksums to the GitHub Release, and updates the brew/scoop stubs (once those repos exist). +From a clean `main`: ```sh -git tag v0.1.0 -git push origin v0.1.0 +make release VERSION=0.1.2 ``` -For a dry run: +That bumps the `VERSION` file (which `docops update-check` reads via raw.githubusercontent.com), commits the bump, creates an annotated `v0.1.2` tag, and pushes both to `origin`. The tag triggers `.github/workflows/release.yml`, which verifies that the tag matches the `VERSION` file and then runs goreleaser to build the matrix, attach archives + checksums to the GitHub Release, and update the brew/scoop stubs (once those tap repos exist). + +Preview without writing: + +```sh +make release VERSION=0.1.2 DRY_RUN=1 +``` + +Local snapshot build (no tag, no push): ```sh make release-snapshot ``` +If you tag manually with `git tag` and forget to bump `VERSION`, the release workflow fails fast with a clear error pointing you at `make release`. + ## License MIT — see LICENSE. From 3327876294978cde9ec9c59f5fda7324a15aeb33 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 09:24:46 +0530 Subject: [PATCH 10/11] fix: make release uses diff-index for clean check, not status git status --porcelain treats untracked files as dirty; the docops binary often sits at the repo root after a smoke test and would spuriously block a release. diff-index --quiet HEAD only reports modifications to tracked files, which is what we actually need. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index cf8a1b7..54a605a 100644 --- a/Makefile +++ b/Makefile @@ -44,8 +44,8 @@ release: fi @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || \ (echo "VERSION must match X.Y.Z (got $(VERSION))" && exit 2) - @if [ -n "$$(git status --porcelain)" ]; then \ - echo "working tree not clean; commit or stash first"; exit 2; \ + @if ! git diff-index --quiet HEAD --; then \ + echo "tracked files have uncommitted changes; commit or stash first"; exit 2; \ fi @branch=$$(git rev-parse --abbrev-ref HEAD); \ if [ "$$branch" != "main" ]; then \ From c281faaaf51131d9460efb66b7778e86d1b586d5 Mon Sep 17 00:00:00 2001 From: nachiket Date: Thu, 23 Apr 2026 09:25:22 +0530 Subject: [PATCH 11/11] chore: untrack .DS_Store (already in .gitignore) --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c441bdd6b82317edf32fa0a5b90e2892a9d3b5e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6u#fIzzh>`s4Wy^*|jTBp$$t56+~g%-9~;|{l;M+|Ut;zxYFxKGD9<>n0j;RF84@J}f4R;T$R zo;pLE)1VC^5Jcei2)JkWN~SW4<(NJB{GJ^)blZu=E|F! zmD=Riv4+U#`leVzWKDC+*q9{NL|1Ltojz;~nbtAx0{qVan{$gx$0z%v z+BqXj&*$n)IB5*5PUWnj;Yo%!Wp**aFEA=nLe zi??s-+;#CjWnPsm*UaxJ>b9OWw7!g;(@a%22f9nT>J0YND=;1Xkgnw=X&xH7%*>E> zv8s0Aq6Zd-LR{yjO?j5yLRLRg(47$_DvErKP+e3Kl~emva-5FI+5(?EV7sb6U zKatm{7YE#hJ2JMjV^}jM9x1U|6#MPLOkUejB9xXbIz-WXjl_3N z+Ev}L@?JEy*M&lT?o_R+s;T7kqS}R3p;V|_R4$N>qMJpX@`Bz0PY*F###c1Hf_I~z z9y#dsyckF0G0v{$WTJI~2@e*Fc z>o|co@iyMa2lxz9p1jC6xbx3W!*M2$ms6sNRS;T9HIMA$uow zA%!mNM;Zr_C7c^DVc`h!7{wT&d>l{X7@o%qcoDDQRl@oSyg^8R2k+uNe1uatjWhVf z!~9qH2AA>UEhz4sieg-wjN&0Hne=bG#ky;UZZw`f-#S&U{-u0)aQe-tn97@mWe(7M z<&2v8D0e%m;1krk=l}NU@Bi<1nZQ1XKoEg@I0C3lrjl*+fYW_u+_QFs>Orb*arMSI t^-QRlauGn|i9ZafA0buOChpU5PKiV1U%v?W+kR>X`+u