▍ humdrum codex / custard v0.3.0
license AGPL-3.0

fix(forge): issues by activity order + tags newest-first

0355245c153c6949448f1870d0d923c70e49e9e5
humdrum <me@humdrum.me> · 2026-06-18 09:17

parent af4dbb0e

fix(forge): issues by activity order + tags newest-first

- issues groups sorted in progress → paused → backlog → done → other
  (by StatusKind, independent of repo config order)
- refs tags sorted semver-descending (newest version on top)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed

- → custard-Claude-skill-plugin.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-008
+title: custard Claude skill + plugin
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-18 16:11'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 8000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Teach Claude the custard/soft-serve workflow so it can drive repos for the user and for anyone installing custard. Core deliverable is a SKILL.md (when/how to use check/preview/promote/release, soft-serve conventions, .custard.yaml schema, tags-as-versions, gotchas, safety rules). Wrap it as an installable Claude Code plugin (skill + slash commands) for distribution. MCP deferred until custard has a JSON API (separate task).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 SKILL.md: soft-serve conventions (soft: remotes, ssh soft admin, /git clone) + custard rhythm (push → check → preview → promote) + .custard.yaml schema + tags-as-versions + release
+- [ ] #2 Documents gotchas: preview builds HEAD, requires pushed, excludes uncommitted; promote/release are prod/publish
+- [ ] #3 Safety: check/preview safe to run; promote/release require explicit user confirmation (prod/publish)
+- [ ] #4 Generic/adoptable — reads .custard.yaml/config, no hardcoded domain (personal specifics stay in user's CLAUDE.md)
+- [ ] #5 Packaged as a Claude Code plugin (skill + optional /preview /promote /ship slash commands); installable; hosted in custard repo or its own soft repo
+- [ ] #6 MCP deferred to a follow-up (needs custard JSON API first)
+<!-- AC:END -->
internal/gitread/gitread.go +12 −1
@@ -259,7 +259,18 @@ 			return nil
 		})
 	}
 	sort.Slice(out.Branches, func(i, j int) bool { return out.Branches[i].Name < out.Branches[j].Name })
-	sort.Slice(out.Tags, func(i, j int) bool { return out.Tags[i].Name < out.Tags[j].Name })
+	// Tags newest-first: semver descending, non-semver after (reverse-lexical).
+	sort.Slice(out.Tags, func(i, j int) bool {
+		vi, oki := parseSemver(out.Tags[i].Name)
+		vj, okj := parseSemver(out.Tags[j].Name)
+		if oki && okj {
+			return semverLess(vj, vi)
+		}
+		if oki != okj {
+			return oki
+		}
+		return out.Tags[i].Name > out.Tags[j].Name
+	})
 	return out, nil
 }
 
internal/server/server.go +1 −2
@@ -261,10 +261,9 @@ 	if err != nil {
 		s.notFound(w, r)
 		return
 	}
-	cfg := backlog.ReadConfig(s.store, name, ref)
 	page := templates.IssuesPage{
 		Meta:   s.meta(r, name, ref, "issues"),
-		Groups: templates.GroupByStatus(tasks, cfg.Statuses),
+		Groups: templates.GroupByStatus(tasks),
 		Total:  len(tasks),
 	}
 	s.render(w, r, templates.Issues(page))
web/templates/view.go +40 −25
@@ -5,6 +5,7 @@
 import (
 	"fmt"
 	"html/template"
+	"sort"
 	"strings"
 	"time"
 
@@ -16,13 +17,13 @@ )
 
 // Meta is the shared chrome data every page needs.
 type Meta struct {
-	Title     string
-	Repo      string           // current repo name, "" on the index
-	Ref       string           // current ref, when relevant
-	HasIssues bool             // show the issues tab only when backlog tasks exist
-	HasReadme bool             // show the readme tab only when a README exists
-	Theme     string           // active data-theme, resolved from cookie (default flexoki)
-	Tab       string           // active repo tab: code | readme | log | refs | issues
+	Title       string
+	Repo        string           // current repo name, "" on the index
+	Ref         string           // current ref, when relevant
+	HasIssues   bool             // show the issues tab only when backlog tasks exist
+	HasReadme   bool             // show the readme tab only when a README exists
+	Theme       string           // active data-theme, resolved from cookie (default flexoki)
+	Tab         string           // active repo tab: code | readme | log | refs | issues
 	CloneURL    string           // read-only HTTP clone URL, shown in the footer bar
 	License     *license.License // detected repo license, shown as a badge
 	DeployState string           // production | preview | unverified | "" (no badge)
@@ -156,32 +157,46 @@ 	Task backlog.Task
 	Body template.HTML
 }
 
-// GroupByStatus buckets tasks following the repo's configured status order,
-// appending any statuses not in that order at the end. Empty buckets are
-// dropped. An empty order falls back to the Backlog.md defaults.
-func GroupByStatus(tasks []backlog.Task, order []string) []IssueGroup {
-	if len(order) == 0 {
-		order = backlog.DefaultStatuses
-	}
-	known := make(map[string]bool, len(order))
-	for _, s := range order {
-		known[s] = true
-	}
+// GroupByStatus buckets tasks and orders the groups by activity: in progress,
+// paused, backlog, done, then anything unrecognized — regardless of how a repo
+// words its statuses (matched via StatusKind).
+func GroupByStatus(tasks []backlog.Task) []IssueGroup {
 	byStatus := map[string][]backlog.Task{}
-	var extra []string
+	var statuses []string
 	for _, t := range tasks {
-		if _, seen := byStatus[t.Status]; !seen && !known[t.Status] {
-			extra = append(extra, t.Status)
+		if _, seen := byStatus[t.Status]; !seen {
+			statuses = append(statuses, t.Status)
 		}
 		byStatus[t.Status] = append(byStatus[t.Status], t)
 	}
-	var groups []IssueGroup
-	for _, s := range append(append([]string{}, order...), extra...) {
-		if ts := byStatus[s]; len(ts) > 0 {
-			groups = append(groups, IssueGroup{Status: s, Tasks: ts})
+	sort.SliceStable(statuses, func(i, j int) bool {
+		ri, rj := statusRank(statuses[i]), statusRank(statuses[j])
+		if ri != rj {
+			return ri < rj
 		}
+		return statuses[i] < statuses[j]
+	})
+	groups := make([]IssueGroup, 0, len(statuses))
+	for _, s := range statuses {
+		groups = append(groups, IssueGroup{Status: s, Tasks: byStatus[s]})
 	}
 	return groups
+}
+
+// statusRank orders status buckets: in progress → paused → backlog → done → other.
+func statusRank(status string) int {
+	switch StatusKind(status) {
+	case "in-progress":
+		return 0
+	case "paused":
+		return 1
+	case "backlog":
+		return 2
+	case "done":
+		return 3
+	default:
+		return 4
+	}
 }
 
 // LabelColor maps a label to a theme color-family name (phase-3 tokens key off