chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-17 02:44:02 +02:00
commit 46b0826a9f
17 changed files with 2090 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
dev_console
+75
View File
@@ -0,0 +1,75 @@
# dev_console
CLI unificado para listar, inspeccionar y gestionar issues y flows del registry.
## Build
```bash
cd apps/dev_console
CGO_ENABLED=0 go build -o dev_console .
```
## Uso
```bash
# Listar issues con filtros
./dev_console issue list
./dev_console issue list --status pendiente
./dev_console issue list --domain trading --prio alta
./dev_console issue list --type epic
# Ver un issue concreto
./dev_console issue show 0099
./dev_console issue status 0099
# Board Kanban en terminal
./dev_console issue board
# Flows
./dev_console flow list
./dev_console flow list --risk high
./dev_console flow show 0001
./dev_console flow status 0001
# Work: que hacer hoy
./dev_console work today
./dev_console work dashboard # JSON para dashboards
# Salida JSON (todos los subcomandos)
./dev_console issue list --json | jq '.[] | .id'
```
## Entorno
`FN_REGISTRY_ROOT` — directorio raiz del registry. Si no se setea, se
auto-detecta subiendo desde el cwd hasta encontrar `registry.db`.
## Auto-deteccion de raiz
El binario puede lanzarse desde cualquier directorio dentro del registry:
```bash
# Desde la raiz
./apps/dev_console/dev_console issue list
# Con env var explicita
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./dev_console issue list
```
## Subcomandos v2 (stub)
Los siguientes subcomandos imprimen "TODO v2" y salen con exit code 2:
- `issue dep|roadmap|tag|done|stale|create`
- `flow create|dod|trace|user-test|run|chain|done`
- `work weekly|search`
## Source
- `main.go` — entrypoint + dispatch + flag parsing
- `parser.go` — ParseIssue / ParseFlow / LoadAllIssues / LoadAllFlows
- `issue.go` — subcomandos de issue
- `flow.go` — subcomandos de flow
- `work.go` — subcomandos de work
- `format.go` — tabwriter helpers + JSON renderer
- `parser_test.go` — unit tests con fixtures en testdata/
+25
View File
@@ -0,0 +1,25 @@
---
id: dev_console
name: dev_console
lang: go
domain: tools
description: "CLI unificado para listar/inspeccionar/gestionar issues + flows del registry. Reemplaza grep ad-hoc sobre dev/issues + dev/flows. Issue 0101."
tags: [cli, registry, issues, flows, work]
uses_functions: []
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "apps/dev_console"
repo_url: ""
e2e_checks:
- id: build
cmd: "cd apps/dev_console && CGO_ENABLED=0 go build -o dev_console ."
- id: tests
cmd: "cd apps/dev_console && go test -count=1 ./..."
- id: list_smoke
cmd: "apps/dev_console/dev_console issue list --status pendiente | head -5"
- id: flow_smoke
cmd: "apps/dev_console/dev_console flow list | head -5"
- id: json_smoke
cmd: "apps/dev_console/dev_console issue list --json | python3 -c \"import json,sys; d=json.load(sys.stdin); assert len(d) > 100, f'expected >100 issues, got {len(d)}'\""
---
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
)
// cmdFlow dispatches flow subcommands.
func cmdFlow(args []string, flags Flags) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: dev_console flow <list|show|status> [args]")
os.Exit(1)
}
sub := args[0]
rest := args[1:]
switch sub {
case "list":
flowList(rest, flags)
case "show":
flowShow(rest, flags)
case "status":
flowStatus(rest, flags)
// v2 stubs
case "create", "dod", "trace", "user-test", "run", "chain", "done":
fmt.Fprintf(os.Stderr, "TODO v2: flow %s not yet implemented\n", sub)
os.Exit(2)
default:
fmt.Fprintf(os.Stderr, "unknown flow subcommand: %s\n", sub)
os.Exit(1)
}
}
// flowList lists flows with optional filters.
func flowList(args []string, flags Flags) {
root := mustRegistryRoot()
flows, err := LoadAllFlows(root)
if err != nil {
fatalf("load flows: %v", err)
}
// Apply filters
var filtered []Flow
for _, fl := range flows {
if flags.App != "" && !matchApp(fl.Apps, flags.App) {
continue
}
if flags.Pattern != "" && !matchStr(fl.Pattern, flags.Pattern) {
continue
}
if flags.Risk != "" && !matchStr(fl.Risk, flags.Risk) {
continue
}
filtered = append(filtered, fl)
}
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].ID < filtered[j].ID
})
if flags.JSON {
printJSON(filtered)
return
}
headers := []string{"ID", "NAME", "STATUS", "PRIO", "RISK", "APPS", "ACCEPTANCE", "DOD", "USER-FACING"}
var rows [][]string
for _, fl := range filtered {
rows = append(rows, []string{
fl.ID,
truncate(fl.Name, 30),
statusShort(fl.Status),
fl.Priority,
fl.Risk,
truncate(joinStrings(fl.Apps), 30),
pctBar(fl.AcceptancePct),
pctBar(fl.DoDPct),
pctBar(fl.UserFacingPct),
})
}
printTable(os.Stdout, headers, rows)
fmt.Printf("\nTotal: %d flows\n", len(filtered))
}
// flowShow prints the full content of a flow.
func flowShow(args []string, flags Flags) {
if len(args) == 0 {
fatalf("usage: dev_console flow show NNNN")
}
id := normalizeID(args[0])
root := mustRegistryRoot()
fl, err := findFlowByID(root, id)
if err != nil {
fatalf("flow %s: %v", id, err)
}
if flags.JSON {
printJSON(fl)
return
}
fmt.Printf("# Flow %s — %s\n\n", fl.ID, fl.Name)
fmt.Printf("Status: %s\n", statusShort(fl.Status))
fmt.Printf("Priority: %s\n", fl.Priority)
fmt.Printf("Risk: %s\n", fl.Risk)
fmt.Printf("Apps: %s\n", joinStrings(fl.Apps))
fmt.Printf("Trigger: %s\n", fl.Trigger)
fmt.Printf("Schedule: %s\n", fl.Schedule)
fmt.Printf("ExpectedRuntime: %ds\n", fl.ExpectedRuntimeS)
fmt.Printf("Tags: %s\n", joinStrings(fl.Tags))
fmt.Printf("Created: %s\n", fl.Created)
fmt.Printf("Updated: %s\n", fl.Updated)
fmt.Printf("Path: %s\n", fl.Path)
fmt.Printf("\nAcceptance: %s\n", pctBar(fl.AcceptancePct))
fmt.Printf("DoD: %s\n", pctBar(fl.DoDPct))
fmt.Printf("User-facing: %s\n", pctBar(fl.UserFacingPct))
fmt.Printf("\n---\n%s\n", fl.Body)
}
// flowStatus prints acceptance + DoD + user-facing % for a flow.
func flowStatus(args []string, flags Flags) {
if len(args) == 0 {
fatalf("usage: dev_console flow status NNNN")
}
id := normalizeID(args[0])
root := mustRegistryRoot()
fl, err := findFlowByID(root, id)
if err != nil {
fatalf("flow %s: %v", id, err)
}
type statusOut struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
UserFacingPct int `json:"user_facing_pct"`
DoDComplete bool `json:"dod_complete"`
}
out := statusOut{
ID: fl.ID,
Name: fl.Name,
Status: fl.Status,
AcceptancePct: fl.AcceptancePct,
DoDPct: fl.DoDPct,
UserFacingPct: fl.UserFacingPct,
DoDComplete: fl.DoDPct == 100,
}
if flags.JSON {
printJSON(out)
return
}
fmt.Printf("Flow %s — %s\n", fl.ID, fl.Name)
fmt.Printf("Status: %s\n", statusShort(fl.Status))
fmt.Printf("Acceptance: %s\n", pctBar(fl.AcceptancePct))
fmt.Printf("DoD: %s\n", pctBar(fl.DoDPct))
fmt.Printf("User-facing: %s\n", pctBar(fl.UserFacingPct))
if fl.DoDPct == 100 {
fmt.Println("DoD: COMPLETE (100%)")
} else {
fmt.Printf("DoD: INCOMPLETE (%d%%)\n", fl.DoDPct)
}
}
// findFlowByID searches for a flow by ID.
func findFlowByID(root, id string) (Flow, error) {
flows, err := LoadAllFlows(root)
if err != nil {
return Flow{}, err
}
for _, fl := range flows {
if fl.ID == id {
return fl, nil
}
}
return Flow{}, fmt.Errorf("not found")
}
// matchApp returns true if any of the flow's apps matches the filter.
func matchApp(apps []string, filter string) bool {
filter = strings.ToLower(filter)
for _, a := range apps {
if strings.ToLower(a) == filter {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
)
// newTabWriter returns a tabwriter for stdout.
func newTabWriter() *tabwriter.Writer {
return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
}
// printTable writes a header + rows to a tabwriter.
// headers: e.g. []string{"ID", "TITLE", "TYPE"}
// rows: [][]string
func printTable(w io.Writer, headers []string, rows [][]string) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
}
tw.Flush()
}
// printJSON marshals v to JSON and writes to stdout.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "json encode error:", err)
os.Exit(1)
}
}
// truncate returns s truncated to maxLen chars, with "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// depsStatus returns "OK" or "blocked: X,Y" for display.
func depsStatus(issue Issue) string {
if len(issue.Depends) == 0 {
return "OK"
}
if issue.DepsResolved {
return "OK"
}
return "blocked: " + strings.Join(issue.Depends, ",")
}
// joinStrings joins a slice with commas, or returns "-" if empty.
func joinStrings(ss []string) string {
if len(ss) == 0 {
return "-"
}
return strings.Join(ss, ",")
}
// pctBar returns a compact string like "75%" or "0%".
func pctBar(p int) string {
return fmt.Sprintf("%d%%", p)
}
// statusShort normalizes status for display.
func statusShort(s string) string {
switch strings.ToLower(s) {
case "in-progress":
return "in-progress"
case "completado", "done", "completed":
return "completado"
case "pendiente", "pending":
return "pendiente"
case "bloqueado", "blocked":
return "bloqueado"
case "deferred":
return "deferred"
default:
if s == "" {
return "-"
}
return s
}
}
+7
View File
@@ -0,0 +1,7 @@
module dev_console
go 1.21
require gopkg.in/yaml.v3 v3.0.1
require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+7
View File
@@ -0,0 +1,7 @@
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+349
View File
@@ -0,0 +1,349 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"time"
)
// cmdIssue dispatches issue subcommands.
func cmdIssue(args []string, flags Flags) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: dev_console issue <list|show|status|board> [args]")
os.Exit(1)
}
sub := args[0]
rest := args[1:]
switch sub {
case "list":
issueList(rest, flags)
case "show":
issueShow(rest, flags)
case "status":
issueStatus(rest, flags)
case "board":
issueBoard(flags)
// v2 stubs
case "dep", "roadmap", "tag", "done", "stale", "create":
fmt.Fprintf(os.Stderr, "TODO v2: issue %s not yet implemented\n", sub)
os.Exit(2)
default:
fmt.Fprintf(os.Stderr, "unknown issue subcommand: %s\n", sub)
os.Exit(1)
}
}
// issueList lists issues with optional filters.
func issueList(args []string, flags Flags) {
root := mustRegistryRoot()
issues, err := LoadAllIssues(root)
if err != nil {
fatalf("load issues: %v", err)
}
ComputeDepsResolved(issues)
// Apply filters
var filtered []Issue
for _, iss := range issues {
if flags.Status != "" && !matchStatus(iss.Status, flags.Status) {
continue
}
if flags.Domain != "" && !matchDomain(iss.Domain, flags.Domain) {
continue
}
if flags.IssueType != "" && !matchStr(iss.Type, flags.IssueType) {
continue
}
if flags.Prio != "" && !matchStr(iss.Priority, flags.Prio) {
continue
}
filtered = append(filtered, iss)
}
// Sort by ID
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].ID < filtered[j].ID
})
if flags.JSON {
printJSON(filtered)
return
}
// Table output
headers := []string{"ID", "TITLE", "TYPE", "DOMAIN", "PRIO", "STATUS", "DEPS"}
var rows [][]string
for _, iss := range filtered {
rows = append(rows, []string{
iss.ID,
truncate(iss.Title, 50),
iss.Type,
truncate(joinStrings(iss.Domain), 20),
iss.Priority,
statusShort(iss.Status),
depsStatus(iss),
})
}
printTable(os.Stdout, headers, rows)
fmt.Printf("\nTotal: %d issues\n", len(filtered))
}
// issueShow prints the full content of an issue.
func issueShow(args []string, flags Flags) {
if len(args) == 0 {
fatalf("usage: dev_console issue show NNNN")
}
id := normalizeID(args[0])
root := mustRegistryRoot()
iss, err := findIssueByID(root, id)
if err != nil {
fatalf("issue %s: %v", id, err)
}
if flags.JSON {
printJSON(iss)
return
}
fmt.Printf("# %s — %s\n\n", iss.ID, iss.Title)
fmt.Printf("Status: %s\n", statusShort(iss.Status))
fmt.Printf("Type: %s\n", iss.Type)
fmt.Printf("Domain: %s\n", joinStrings(iss.Domain))
fmt.Printf("Scope: %s\n", iss.Scope)
fmt.Printf("Priority: %s\n", iss.Priority)
fmt.Printf("Depends: %s\n", joinStrings(iss.Depends))
fmt.Printf("Blocks: %s\n", joinStrings(iss.Blocks))
fmt.Printf("Related: %s\n", joinStrings(iss.Related))
fmt.Printf("Tags: %s\n", joinStrings(iss.Tags))
fmt.Printf("Created: %s\n", iss.Created)
fmt.Printf("Updated: %s\n", iss.Updated)
fmt.Printf("Path: %s\n", iss.Path)
fmt.Printf("\nAcceptance: %s\n", pctBar(iss.AcceptancePct))
fmt.Printf("DoD: %s\n", pctBar(iss.DoDPct))
fmt.Printf("\n---\n%s\n", iss.Body)
}
// issueStatus prints acceptance % and dep status.
func issueStatus(args []string, flags Flags) {
if len(args) == 0 {
fatalf("usage: dev_console issue status NNNN")
}
id := normalizeID(args[0])
root := mustRegistryRoot()
issues, err := LoadAllIssues(root)
if err != nil {
fatalf("load issues: %v", err)
}
ComputeDepsResolved(issues)
var iss *Issue
for i := range issues {
if issues[i].ID == id {
iss = &issues[i]
break
}
}
if iss == nil {
fatalf("issue %s not found", id)
}
type statusOut struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
Depends []string `json:"depends"`
DepsResolved bool `json:"deps_resolved"`
}
out := statusOut{
ID: iss.ID,
Title: iss.Title,
Status: iss.Status,
AcceptancePct: iss.AcceptancePct,
DoDPct: iss.DoDPct,
Depends: iss.Depends,
DepsResolved: iss.DepsResolved,
}
if flags.JSON {
printJSON(out)
return
}
fmt.Printf("Issue %s — %s\n", iss.ID, iss.Title)
fmt.Printf("Status: %s\n", statusShort(iss.Status))
fmt.Printf("Acceptance: %s\n", pctBar(iss.AcceptancePct))
fmt.Printf("DoD: %s\n", pctBar(iss.DoDPct))
fmt.Printf("Depends: %s\n", joinStrings(iss.Depends))
if iss.DepsResolved {
fmt.Println("Deps: resolved")
} else {
fmt.Printf("Deps: BLOCKED on: %s\n", joinStrings(iss.Depends))
}
}
// issueBoard prints a 4-column view: pendiente / in-progress / bloqueado / completado (recent).
func issueBoard(flags Flags) {
root := mustRegistryRoot()
issues, err := LoadAllIssues(root)
if err != nil {
fatalf("load issues: %v", err)
}
ComputeDepsResolved(issues)
type col struct {
name string
issues []Issue
}
cols := []*col{
{name: "PENDIENTE"},
{name: "IN-PROGRESS"},
{name: "BLOQUEADO"},
{name: "COMPLETADO-24H"},
}
cutoff := time.Now().UTC().Add(-24 * time.Hour)
for _, iss := range issues {
st := strings.ToLower(iss.Status)
switch {
case st == "in-progress":
cols[1].issues = append(cols[1].issues, iss)
case st == "bloqueado" || st == "blocked":
cols[2].issues = append(cols[2].issues, iss)
case st == "completado" || st == "done" || st == "completed":
// Only show completado if updated within 24h
t, err := time.Parse("2006-01-02", iss.Updated)
if err == nil && t.After(cutoff) {
cols[3].issues = append(cols[3].issues, iss)
}
default:
// pendiente, deferred, pending, etc.
cols[0].issues = append(cols[0].issues, iss)
}
}
type boardOut struct {
Pendiente []Issue `json:"pendiente"`
InProgress []Issue `json:"in_progress"`
Bloqueado []Issue `json:"bloqueado"`
Completado []Issue `json:"completado_24h"`
}
if flags.JSON {
out := boardOut{
Pendiente: cols[0].issues,
InProgress: cols[1].issues,
Bloqueado: cols[2].issues,
Completado: cols[3].issues,
}
printJSON(out)
return
}
// Find max rows
maxRows := 0
for _, c := range cols {
if len(c.issues) > maxRows {
maxRows = len(c.issues)
}
}
// Print header
headerRow := make([]string, len(cols))
for i, c := range cols {
headerRow[i] = fmt.Sprintf("%s (%d)", c.name, len(c.issues))
}
fmt.Println(strings.Join(headerRow, " | "))
fmt.Println(strings.Repeat("-", 100))
// Print rows
maxDisplay := maxRows
if maxDisplay > 20 {
maxDisplay = 20
}
for r := 0; r < maxDisplay; r++ {
rowParts := make([]string, len(cols))
for c, col := range cols {
if r < len(col.issues) {
iss := col.issues[r]
rowParts[c] = fmt.Sprintf("%-6s %s", iss.ID, truncate(iss.Title, 28))
} else {
rowParts[c] = ""
}
}
fmt.Println(strings.Join(rowParts, " | "))
}
}
// findIssueByID searches for an issue by ID in all issue directories.
func findIssueByID(root, id string) (Issue, error) {
issues, err := LoadAllIssues(root)
if err != nil {
return Issue{}, err
}
ComputeDepsResolved(issues)
for _, iss := range issues {
if iss.ID == id {
return iss, nil
}
}
return Issue{}, fmt.Errorf("not found")
}
// matchStatus returns true if the issue status matches the filter.
// Handles normalization: pending=pendiente, done=completado, etc.
func matchStatus(issueStatus, filter string) bool {
norm := func(s string) string {
s = strings.ToLower(s)
switch s {
case "pending":
return "pendiente"
case "done", "completed":
return "completado"
case "blocked":
return "bloqueado"
}
return s
}
return norm(issueStatus) == norm(filter)
}
// matchDomain returns true if any of the issue domains contains the filter.
func matchDomain(domains []string, filter string) bool {
filter = strings.ToLower(filter)
for _, d := range domains {
if strings.ToLower(d) == filter {
return true
}
}
return false
}
// matchStr returns true if s == filter (case-insensitive).
func matchStr(s, filter string) bool {
return strings.ToLower(s) == strings.ToLower(filter)
}
// normalizeID trims leading zeros? No — keep original. But accept "99" -> "0099".
func normalizeID(s string) string {
// If it's purely numeric and < 4 chars, pad to 4
s = strings.TrimSpace(s)
// Check if it's purely numeric
allNum := true
for _, c := range s {
if c < '0' || c > '9' {
allNum = false
break
}
}
if allNum && len(s) < 4 {
return fmt.Sprintf("%04s", s)
}
return s
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Flags holds all parsed CLI flags.
type Flags struct {
JSON bool
Domain string
IssueType string // --type
Status string
Prio string
App string
Pattern string
Risk string
}
func main() {
args := os.Args[1:]
if len(args) == 0 {
printUsage()
os.Exit(0)
}
// Parse global flags and collect remaining args
var noun, verb string
var rest []string
flags := Flags{}
// First arg is noun, second is verb, rest are flags/args
pos := 0
for pos < len(args) {
arg := args[pos]
if strings.HasPrefix(arg, "--") {
flag, val, ok := parseFlag(args, pos)
pos += flag.consumed
if !ok {
fmt.Fprintf(os.Stderr, "unknown flag: %s\n", arg)
os.Exit(1)
}
applyFlag(&flags, flag.name, val)
} else {
// Positional
if noun == "" {
noun = arg
} else if verb == "" {
verb = arg
} else {
rest = append(rest, arg)
}
pos++
}
}
// Dispatch
switch noun {
case "issue":
if verb == "" {
fmt.Fprintln(os.Stderr, "usage: dev_console issue <list|show|status|board>")
os.Exit(1)
}
cmdIssue(append([]string{verb}, rest...), flags)
case "flow":
if verb == "" {
fmt.Fprintln(os.Stderr, "usage: dev_console flow <list|show|status>")
os.Exit(1)
}
cmdFlow(append([]string{verb}, rest...), flags)
case "work":
if verb == "" {
fmt.Fprintln(os.Stderr, "usage: dev_console work <today|dashboard>")
os.Exit(1)
}
cmdWork(append([]string{verb}, rest...), flags)
case "help", "--help", "-h":
printUsage()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", noun)
printUsage()
os.Exit(1)
}
}
type parsedFlag struct {
name string
consumed int
}
func parseFlag(args []string, pos int) (parsedFlag, string, bool) {
arg := args[pos]
// Strip leading --
name := strings.TrimPrefix(arg, "--")
// Check for --key=value form
if idx := strings.Index(name, "="); idx != -1 {
key := name[:idx]
val := name[idx+1:]
return parsedFlag{name: key, consumed: 1}, val, true
}
// Boolean flags (no value)
switch name {
case "json":
return parsedFlag{name: name, consumed: 1}, "true", true
}
// Flags that take a value
switch name {
case "domain", "type", "status", "prio", "app", "pattern", "risk", "epic", "days":
if pos+1 < len(args) && !strings.HasPrefix(args[pos+1], "--") {
return parsedFlag{name: name, consumed: 2}, args[pos+1], true
}
return parsedFlag{name: name, consumed: 1}, "", true
}
return parsedFlag{consumed: 1}, "", false
}
func applyFlag(flags *Flags, name, val string) {
switch name {
case "json":
flags.JSON = true
case "domain":
flags.Domain = val
case "type":
flags.IssueType = val
case "status":
flags.Status = val
case "prio":
flags.Prio = val
case "app":
flags.App = val
case "pattern":
flags.Pattern = val
case "risk":
flags.Risk = val
}
}
func printUsage() {
fmt.Print(`dev_console — CLI unificado para issues + flows del registry
Usage:
dev_console issue list [--domain X] [--type Y] [--status Z] [--prio P] [--json]
dev_console issue show NNNN [--json]
dev_console issue status NNNN [--json]
dev_console issue board [--json]
dev_console flow list [--app X] [--pattern P] [--risk R] [--json]
dev_console flow show NNNN [--json]
dev_console flow status NNNN [--json]
dev_console work today [--json]
dev_console work dashboard
Stubs (v2):
issue dep|roadmap|tag|done|stale|create
flow create|dod|trace|user-test|run|chain|done
work weekly|search
Env:
FN_REGISTRY_ROOT Root of the fn_registry repo (auto-detected if not set)
`)
}
// mustRegistryRoot returns the registry root or exits.
func mustRegistryRoot() string {
root, err := findRegistryRoot()
if err != nil {
fatalf("cannot find registry root: %v\nSet FN_REGISTRY_ROOT env var or run from within fn_registry.", err)
}
return root
}
// findRegistryRoot tries FN_REGISTRY_ROOT, then walks up from cwd looking for registry.db.
func findRegistryRoot() (string, error) {
if r := os.Getenv("FN_REGISTRY_ROOT"); r != "" {
return r, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("registry.db not found walking up from %s", cwd)
}
// fatalf prints an error to stderr and exits with code 1.
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
+418
View File
@@ -0,0 +1,418 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// Issue represents a parsed dev/issues/*.md file.
type Issue struct {
ID string `yaml:"id" json:"id"`
Title string `yaml:"title" json:"title"`
Status string `yaml:"status" json:"status"`
Type string `yaml:"type" json:"type"`
Domain []string `yaml:"domain" json:"domain"`
Scope string `yaml:"scope" json:"scope"`
Priority string `yaml:"priority" json:"priority"`
Depends []string `yaml:"depends" json:"depends"`
Blocks []string `yaml:"blocks" json:"blocks"`
Related []string `yaml:"related" json:"related"`
Created string `yaml:"created" json:"created"`
Updated string `yaml:"updated" json:"updated"`
Tags []string `yaml:"tags" json:"tags"`
// Computed fields (not in YAML)
Path string `json:"path"`
Body string `json:"body,omitempty"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
DepsResolved bool `json:"deps_resolved"`
}
// Flow represents a parsed dev/flows/*.md file.
type Flow struct {
Name string `yaml:"name" json:"name"`
ID string `yaml:"id" json:"id"`
Status string `yaml:"status" json:"status"`
Created string `yaml:"created" json:"created"`
Updated string `yaml:"updated" json:"updated"`
Priority string `yaml:"priority" json:"priority"`
Risk string `yaml:"risk" json:"risk"`
RelatedIssues []string `yaml:"related_issues" json:"related_issues"`
Apps []string `yaml:"apps" json:"apps"`
Trigger string `yaml:"trigger" json:"trigger"`
Schedule string `yaml:"schedule" json:"schedule"`
ExpectedRuntimeS int `yaml:"expected_runtime_s" json:"expected_runtime_s"`
Tags []string `yaml:"tags" json:"tags"`
Pattern string `yaml:"pattern" json:"pattern"`
// Computed fields
Path string `json:"path"`
Body string `json:"body,omitempty"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
UserFacingPct int `json:"user_facing_pct"`
}
// splitFrontmatter splits a markdown file into YAML frontmatter and body.
// Returns (yamlStr, body, err).
func splitFrontmatter(path string) (string, string, error) {
f, err := os.Open(path)
if err != nil {
return "", "", fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return "", "", fmt.Errorf("scan %s: %w", path, err)
}
if len(lines) == 0 {
return "", "", nil
}
// Find frontmatter delimiters
if lines[0] != "---" {
// No frontmatter — entire file is body
return "", strings.Join(lines, "\n"), nil
}
end := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
end = i
break
}
}
if end == -1 {
return "", strings.Join(lines, "\n"), nil
}
yamlStr := strings.Join(lines[1:end], "\n")
body := ""
if end+1 < len(lines) {
body = strings.Join(lines[end+1:], "\n")
}
return yamlStr, body, nil
}
// countCheckboxes counts checked and total checkboxes in a section of markdown.
// section is the content of the section (after the heading line).
func countCheckboxes(body string, sectionHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
// Match headings of same or greater depth to detect section end
headingRe := regexp.MustCompile(`^#{1,6}\s`)
var sectionLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Detect section start - flexible: "## Acceptance", "## Definition of Done"
if !inSection {
// case-insensitive heading match
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(sectionHeading)
if strings.HasPrefix(lower, "##") && strings.Contains(lower, headingLower) {
inSection = true
continue
}
} else {
// Stop at any heading of ## or higher
if headingRe.MatchString(trimmed) && (strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "# ")) {
break
}
// Also stop at "### " sub-headings only if they are a different section
// Actually: stop at any heading that's the same level or higher
if strings.HasPrefix(trimmed, "## ") {
break
}
sectionLines = append(sectionLines, line)
}
}
for _, l := range sectionLines {
t := strings.TrimSpace(l)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
return checked, total
}
// pct computes integer percentage (0-100).
func pct(checked, total int) int {
if total == 0 {
return 0
}
return (checked * 100) / total
}
// ParseIssue parses a single issue markdown file.
func ParseIssue(path string) (Issue, error) {
yamlStr, body, err := splitFrontmatter(path)
if err != nil {
return Issue{}, err
}
var issue Issue
if yamlStr != "" {
if err := yaml.Unmarshal([]byte(yamlStr), &issue); err != nil {
return Issue{}, fmt.Errorf("yaml parse %s: %w", path, err)
}
}
issue.Path = path
issue.Body = body
// Compute AcceptancePct
acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance")
issue.AcceptancePct = pct(acceptChecked, acceptTotal)
// Compute DoDPct
dodChecked, dodTotal := countCheckboxes(body, "Definition of Done")
issue.DoDPct = pct(dodChecked, dodTotal)
return issue, nil
}
// ParseFlow parses a single flow markdown file.
func ParseFlow(path string) (Flow, error) {
yamlStr, body, err := splitFrontmatter(path)
if err != nil {
return Flow{}, err
}
var flow Flow
if yamlStr != "" {
if err := yaml.Unmarshal([]byte(yamlStr), &flow); err != nil {
return Flow{}, fmt.Errorf("yaml parse %s: %w", path, err)
}
}
flow.Path = path
flow.Body = body
// Compute AcceptancePct
acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance")
flow.AcceptancePct = pct(acceptChecked, acceptTotal)
// Compute DoDPct from full DoD section (all checkboxes in that section)
dodChecked, dodTotal := countCheckboxesDeep(body, "Definition of Done")
flow.DoDPct = pct(dodChecked, dodTotal)
// UserFacingPct from ### User-facing sub-block
ufChecked, ufTotal := countCheckboxesSubsection(body, "User-facing")
flow.UserFacingPct = pct(ufChecked, ufTotal)
return flow, nil
}
// countCheckboxesDeep counts all checkboxes under a section heading (## or ###), including sub-sections.
func countCheckboxesDeep(body, sectionHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
sectionDepth := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(sectionHeading)
if !inSection {
if isHeading(trimmed) && strings.Contains(lower, headingLower) {
inSection = true
sectionDepth = headingDepth(trimmed)
continue
}
} else {
// Stop if we hit a heading at same or lesser depth
if isHeading(trimmed) && headingDepth(trimmed) <= sectionDepth {
break
}
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
}
return checked, total
}
// countCheckboxesSubsection counts checkboxes under a ### sub-heading.
func countCheckboxesSubsection(body, subHeading string) (checked, total int) {
lines := strings.Split(body, "\n")
inSection := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
headingLower := strings.ToLower(subHeading)
if !inSection {
if isHeading(trimmed) && strings.Contains(lower, headingLower) {
inSection = true
continue
}
} else {
if isHeading(trimmed) {
break
}
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") {
checked++
total++
} else if strings.HasPrefix(t, "- [ ]") {
total++
}
}
}
return checked, total
}
func isHeading(line string) bool {
return regexp.MustCompile(`^#{1,6}\s`).MatchString(line)
}
func headingDepth(line string) int {
for i, c := range line {
if c != '#' {
return i
}
}
return 0
}
// isSkippable returns true for files that should be skipped when loading issues.
func isSkippable(name string) bool {
lower := strings.ToLower(name)
skip := []string{"readme", "template", "index", "agent_guide"}
for _, s := range skip {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// LoadAllIssues loads all issues from dev/issues/ (open) and dev/issues/completed/ (completed).
// If onlyOpen is true, skips completed/.
func LoadAllIssues(root string) ([]Issue, error) {
return loadIssuesFromDirs(root, false)
}
func loadIssuesFromDirs(root string, onlyOpen bool) ([]Issue, error) {
dirs := []string{
filepath.Join(root, "dev", "issues"),
}
if !onlyOpen {
dirs = append(dirs, filepath.Join(root, "dev", "issues", "completed"))
}
// Deduplicate by ID: first occurrence wins (dev/issues/ takes precedence over completed/).
seen := make(map[string]bool)
var issues []Issue
for _, dir := range dirs {
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
for _, path := range entries {
name := filepath.Base(path)
if isSkippable(name) {
continue
}
issue, err := ParseIssue(path)
if err != nil {
// Skip malformed files with a warning
fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err)
continue
}
// Deduplicate by ID; if same file name appears in both dirs, keep first seen.
key := issue.ID
if key == "" {
key = name // fallback to filename
}
if seen[key] {
continue
}
seen[key] = true
issues = append(issues, issue)
}
}
return issues, nil
}
// LoadOpenIssues loads only non-completed issues (from dev/issues/, not completed/).
func LoadOpenIssues(root string) ([]Issue, error) {
return loadIssuesFromDirs(root, true)
}
// LoadAllFlows loads all flows from dev/flows/*.md.
func LoadAllFlows(root string) ([]Flow, error) {
dir := filepath.Join(root, "dev", "flows")
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
var flows []Flow
for _, path := range entries {
name := filepath.Base(path)
if isSkippable(name) {
continue
}
flow, err := ParseFlow(path)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err)
continue
}
flows = append(flows, flow)
}
return flows, nil
}
// ComputeDepsResolved sets DepsResolved on each issue based on the full list.
func ComputeDepsResolved(issues []Issue) {
// Build a map of id -> status
statusMap := make(map[string]string)
for _, iss := range issues {
if iss.ID != "" {
statusMap[iss.ID] = iss.Status
}
}
for i := range issues {
if len(issues[i].Depends) == 0 {
issues[i].DepsResolved = true
continue
}
allResolved := true
for _, dep := range issues[i].Depends {
dep = strings.TrimSpace(dep)
if dep == "" {
continue
}
st, found := statusMap[dep]
if !found || st != "completado" {
allResolved = false
break
}
}
issues[i].DepsResolved = allResolved
}
}
+259
View File
@@ -0,0 +1,259 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
// testdataDir returns the path to the testdata directory.
func testdataDir() string {
return "testdata"
}
// TestParseIssue_BasicFields verifies that ParseIssue correctly reads frontmatter.
func TestParseIssue_BasicFields(t *testing.T) {
path := filepath.Join(testdataDir(), "issues", "0099-sample.md")
iss, err := ParseIssue(path)
if err != nil {
t.Fatalf("ParseIssue: %v", err)
}
if iss.ID != "0099" {
t.Errorf("ID: got %q, want %q", iss.ID, "0099")
}
if iss.Title != "datahub app (launcher central para todas las apps)" {
t.Errorf("Title: got %q", iss.Title)
}
if iss.Status != "pendiente" {
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
}
if iss.Priority != "alta" {
t.Errorf("Priority: got %q, want %q", iss.Priority, "alta")
}
if iss.Type != "feature" {
t.Errorf("Type: got %q, want %q", iss.Type, "feature")
}
if len(iss.Domain) != 1 || iss.Domain[0] != "apps-infra" {
t.Errorf("Domain: got %v, want [apps-infra]", iss.Domain)
}
if iss.Path != path {
t.Errorf("Path: got %q, want %q", iss.Path, path)
}
if iss.Body == "" {
t.Error("Body should not be empty")
}
}
// TestParseIssue_AcceptancePct verifies checkbox counting in ## Acceptance.
func TestParseIssue_AcceptancePct(t *testing.T) {
path := filepath.Join(testdataDir(), "issues", "0099-sample.md")
iss, err := ParseIssue(path)
if err != nil {
t.Fatalf("ParseIssue: %v", err)
}
// In 0099-sample.md: 2 checked, 2 unchecked = 50%
if iss.AcceptancePct != 50 {
t.Errorf("AcceptancePct: got %d, want 50", iss.AcceptancePct)
}
}
// TestParseIssue_DoDPct verifies checkbox counting in ## Definition of Done.
func TestParseIssue_DoDPct(t *testing.T) {
path := filepath.Join(testdataDir(), "issues", "0099-sample.md")
iss, err := ParseIssue(path)
if err != nil {
t.Fatalf("ParseIssue: %v", err)
}
// In 0099-sample.md DoD: 1 checked, 3 unchecked = 25%
if iss.DoDPct != 25 {
t.Errorf("DoDPct: got %d, want 25", iss.DoDPct)
}
}
// TestParseIssue_EmptyBody handles a file with no body.
func TestParseIssue_EmptyBody(t *testing.T) {
path := filepath.Join(testdataDir(), "issues", "0051-missing-dep.md")
iss, err := ParseIssue(path)
if err != nil {
t.Fatalf("ParseIssue: %v", err)
}
if iss.ID != "0051" {
t.Errorf("ID: got %q, want %q", iss.ID, "0051")
}
// No checkboxes — both pcts should be 0
if iss.AcceptancePct != 0 {
t.Errorf("AcceptancePct: got %d, want 0", iss.AcceptancePct)
}
}
// TestDepsResolved_AllCompletado verifies DepsResolved=true when all deps are completado.
func TestDepsResolved_AllCompletado(t *testing.T) {
issues := []Issue{
{ID: "0001", Status: "completado"},
{ID: "0050", Status: "pendiente", Depends: []string{"0001"}},
}
ComputeDepsResolved(issues)
if !issues[1].DepsResolved {
t.Error("DepsResolved should be true when all deps are completado")
}
}
// TestDepsResolved_DepNotCompletado verifies DepsResolved=false when dep not completado.
func TestDepsResolved_DepNotCompletado(t *testing.T) {
issues := []Issue{
{ID: "0001", Status: "pendiente"}, // not completado
{ID: "0050", Status: "pendiente", Depends: []string{"0001"}},
}
ComputeDepsResolved(issues)
if issues[1].DepsResolved {
t.Error("DepsResolved should be false when dep is not completado")
}
}
// TestDepsResolved_DepMissing verifies DepsResolved=false when dep is not in list.
func TestDepsResolved_DepMissing(t *testing.T) {
issues := []Issue{
{ID: "0051", Status: "pendiente", Depends: []string{"9999"}},
}
ComputeDepsResolved(issues)
if issues[0].DepsResolved {
t.Error("DepsResolved should be false when dep is missing from issue list")
}
}
// TestDepsResolved_NoDeps verifies DepsResolved=true when there are no deps.
func TestDepsResolved_NoDeps(t *testing.T) {
issues := []Issue{
{ID: "0099", Status: "pendiente", Depends: []string{}},
}
ComputeDepsResolved(issues)
if !issues[0].DepsResolved {
t.Error("DepsResolved should be true when there are no deps")
}
}
// TestLoadAllIssues_SkipsSkippable verifies that README.md and template.md are skipped.
func TestLoadAllIssues_SkipsSkippable(t *testing.T) {
issues, err := loadIssuesFromTestdata()
if err != nil {
t.Fatalf("load: %v", err)
}
for _, iss := range issues {
base := filepath.Base(iss.Path)
lower := strings.ToLower(base)
if strings.Contains(lower, "readme") || strings.Contains(lower, "template") {
t.Errorf("should have skipped %s", base)
}
}
}
// TestLoadAllIssues_CountsCorrect verifies we load the expected number of fixtures.
func TestLoadAllIssues_CountsCorrect(t *testing.T) {
issues, err := loadIssuesFromTestdata()
if err != nil {
t.Fatalf("load: %v", err)
}
// We have 4 fixture issues: 0099, 0001, 0050, 0051
if len(issues) != 4 {
t.Errorf("expected 4 issues, got %d", len(issues))
}
}
// TestParseFlow_BasicFields verifies that ParseFlow reads frontmatter correctly.
func TestParseFlow_BasicFields(t *testing.T) {
path := filepath.Join(testdataDir(), "flows", "0001-sample.md")
fl, err := ParseFlow(path)
if err != nil {
t.Fatalf("ParseFlow: %v", err)
}
if fl.ID != "0001" {
t.Errorf("ID: got %q, want %q", fl.ID, "0001")
}
if fl.Name != "hn-top-stories" {
t.Errorf("Name: got %q, want %q", fl.Name, "hn-top-stories")
}
if fl.Status != "pending" {
t.Errorf("Status: got %q, want %q", fl.Status, "pending")
}
if fl.Risk != "low" {
t.Errorf("Risk: got %q, want %q", fl.Risk, "low")
}
if len(fl.Apps) != 2 {
t.Errorf("Apps: got %d apps, want 2", len(fl.Apps))
}
}
// TestParseFlow_AcceptancePct verifies checkbox counting in flow Acceptance.
func TestParseFlow_AcceptancePct(t *testing.T) {
path := filepath.Join(testdataDir(), "flows", "0001-sample.md")
fl, err := ParseFlow(path)
if err != nil {
t.Fatalf("ParseFlow: %v", err)
}
// 1 checked, 2 unchecked = 33%
if fl.AcceptancePct != 33 {
t.Errorf("AcceptancePct: got %d, want 33", fl.AcceptancePct)
}
}
// TestParseFlow_UserFacingPct verifies user-facing checkbox counting.
func TestParseFlow_UserFacingPct(t *testing.T) {
path := filepath.Join(testdataDir(), "flows", "0001-sample.md")
fl, err := ParseFlow(path)
if err != nil {
t.Fatalf("ParseFlow: %v", err)
}
// 0 checked, 2 unchecked = 0%
if fl.UserFacingPct != 0 {
t.Errorf("UserFacingPct: got %d, want 0", fl.UserFacingPct)
}
}
// TestNormalizeID pads short numeric IDs.
func TestNormalizeID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"99", "0099"},
{"0099", "0099"},
{"1", "0001"},
{"0001", "0001"},
{"0088a", "0088a"}, // non-numeric, no pad
{"101", "0101"},
}
for _, tt := range tests {
got := normalizeID(tt.input)
if got != tt.want {
t.Errorf("normalizeID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// loadIssuesFromTestdata is a helper that loads from testdata/ instead of dev/issues/.
func loadIssuesFromTestdata() ([]Issue, error) {
dir := filepath.Join(testdataDir(), "issues")
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
var issues []Issue
for _, path := range entries {
name := filepath.Base(path)
if isSkippable(name) {
continue
}
iss, err := ParseIssue(path)
if err != nil {
return nil, err
}
issues = append(issues, iss)
}
return issues, nil
}
+40
View File
@@ -0,0 +1,40 @@
---
name: hn-top-stories
id: 0001
status: pending
created: 2026-05-16
updated: 2026-05-16
priority: high
risk: low
related_issues: [0097, 0098]
apps:
- navegator_dashboard
- dag_engine
trigger: cron
schedule: "*/30 * * * *"
expected_runtime_s: 30
tags: [scraping, news, smoke-test]
---
## Goal
Probar end-to-end el stack: navegator -> dag_engine -> data_factory.
## Acceptance
- [x] Recipe creada y validada.
- [ ] DAG corre OK 2 veces consecutivas.
- [ ] data_factory.runs tiene >=2 entries.
## Definition of Done
### Generico
- [x] **Repetibilidad**: corre 3 veces consecutivas via cron sin intervencion.
- [ ] **Observabilidad**: call_monitor.calls registra la funcion.
- [ ] **Error-path**: si Chrome cae, el step falla con mensaje claro.
### User-facing
- [ ] **User-facing**: usuario abre data_factory.exe y ve >=30 filas.
- [ ] **User-facing repeat**: datos frescos cada 30 min.
+27
View File
@@ -0,0 +1,27 @@
---
id: "0001"
title: "setup inicial del registry"
status: completado
type: chore
domain:
- meta
scope: registry-only
priority: alta
depends: []
blocks: []
related: []
created: 2026-01-01
updated: 2026-01-15
tags: [setup]
---
# 0001 — setup inicial del registry
## Acceptance
- [x] Registry indexable con fn index.
- [x] CLI fn disponible.
## Definition of Done
- [x] **Repetibilidad**: fn index corre 3x sin errores.
- [x] **Docs**: CLAUDE.md actualizado.
+23
View File
@@ -0,0 +1,23 @@
---
id: "0050"
title: "feature que depende de 0001"
status: pendiente
type: feature
domain:
- meta
scope: registry-only
priority: media
depends:
- "0001"
blocks: []
related: []
created: 2026-02-01
updated: 2026-02-01
tags: []
---
# 0050 — feature que depende de 0001
## Acceptance
- [ ] Feature implementada.
- [ ] Tests verdes.
+18
View File
@@ -0,0 +1,18 @@
---
id: "0051"
title: "feature con dep no existente"
status: pendiente
type: feature
domain:
- meta
scope: registry-only
priority: baja
depends:
- "9999"
blocks: []
related: []
created: 2026-02-01
updated: 2026-02-01
tags: []
---
# 0051 — feature con dep no existente
+39
View File
@@ -0,0 +1,39 @@
---
id: "0099"
title: "datahub app (launcher central para todas las apps)"
status: pendiente
type: feature
domain:
- apps-infra
scope: app-scoped
priority: alta
depends: []
blocks: []
related: []
created: 2026-05-17
updated: 2026-05-17
tags: []
---
# 0099 — datahub app (launcher central para todas las apps)
**Status:** pendiente
## Problema
App C++ ImGui standalone que actua como launcher central.
## Acceptance
- [x] Catalogo de apps visible con icono, nombre, descripcion.
- [x] Filtro por texto funcional.
- [ ] Launch / Stop / Redeploy funcionales.
- [ ] Tail log de una app corriendo actualizado en vivo.
## Definition of Done
### Generico
- [x] **Repetibilidad**: tests verdes 3x.
- [ ] **Observabilidad**: cada invocacion registrada.
- [ ] **Error-path**: archivo malformado -> mensaje claro.
- [ ] **Docs**: app.md + README con ejemplos.
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
)
// cmdWork dispatches work subcommands.
func cmdWork(args []string, flags Flags) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: dev_console work <today|dashboard> [args]")
os.Exit(1)
}
sub := args[0]
switch sub {
case "today":
workToday(flags)
case "dashboard":
workDashboard(flags)
// v2 stubs
case "weekly", "search":
fmt.Fprintf(os.Stderr, "TODO v2: work %s not yet implemented\n", sub)
os.Exit(2)
default:
fmt.Fprintf(os.Stderr, "unknown work subcommand: %s\n", sub)
os.Exit(1)
}
}
type workItem struct {
Kind string `json:"kind"`
ID string `json:"id"`
Title string `json:"title"`
Prio string `json:"prio"`
Status string `json:"status"`
Next string `json:"next"`
}
// workToday shows a prioritized list of up to 10 items to work on today.
func workToday(flags Flags) {
root := mustRegistryRoot()
// Load issues
issues, err := LoadOpenIssues(root)
if err != nil {
fatalf("load issues: %v", err)
}
ComputeDepsResolved(issues)
// Load flows
flows, err := LoadAllFlows(root)
if err != nil {
fatalf("load flows: %v", err)
}
var items []workItem
// Priority order: alta > media > baja
prioPriority := map[string]int{
"alta": 0,
"media": 1,
"baja": 2,
"high": 0,
"medium": 1,
"low": 2,
"": 3,
}
statusPriority := map[string]int{
"in-progress": 0,
"pendiente": 1,
"pending": 1,
"bloqueado": 2,
"blocked": 2,
"deferred": 3,
}
// Filter issues: only pendiente or in-progress
for _, iss := range issues {
st := strings.ToLower(iss.Status)
if st == "completado" || st == "done" || st == "completed" || st == "deferred" {
continue
}
next := issueNext(iss)
items = append(items, workItem{
Kind: "issue",
ID: iss.ID,
Title: iss.Title,
Prio: iss.Priority,
Status: st,
Next: next,
})
}
// Add flows that are not completed
for _, fl := range flows {
st := strings.ToLower(fl.Status)
if st == "completado" || st == "done" || st == "completed" {
continue
}
next := flowNext(fl)
items = append(items, workItem{
Kind: "flow",
ID: fl.ID,
Title: fl.Name,
Prio: fl.Priority,
Status: st,
Next: next,
})
}
// Sort: first by status priority, then by prio priority, then by ID
sort.Slice(items, func(i, j int) bool {
si := statusPriority[items[i].Status]
sj := statusPriority[items[j].Status]
if si != sj {
return si < sj
}
pi := prioPriority[strings.ToLower(items[i].Prio)]
pj := prioPriority[strings.ToLower(items[j].Prio)]
if pi != pj {
return pi < pj
}
return items[i].ID < items[j].ID
})
// Take top 10
if len(items) > 10 {
items = items[:10]
}
if flags.JSON {
printJSON(items)
return
}
headers := []string{"KIND", "ID", "TITLE", "PRIO", "STATUS", "NEXT"}
var rows [][]string
for _, item := range items {
rows = append(rows, []string{
item.Kind,
item.ID,
truncate(item.Title, 40),
item.Prio,
item.Status,
truncate(item.Next, 30),
})
}
printTable(os.Stdout, headers, rows)
}
// issueNext returns a human-readable "next action" for an issue.
func issueNext(iss Issue) string {
st := strings.ToLower(iss.Status)
if st == "in-progress" {
return "iterate"
}
if !iss.DepsResolved {
return "blocked: resolve deps"
}
if iss.AcceptancePct == 0 {
return "implement (deps ok)"
}
if iss.AcceptancePct < 100 {
return fmt.Sprintf("implement (%d%% done)", iss.AcceptancePct)
}
return "review DoD"
}
// flowNext returns a human-readable "next action" for a flow.
func flowNext(fl Flow) string {
st := strings.ToLower(fl.Status)
if st == "in-progress" {
return "iterate"
}
if fl.DoDPct == 100 {
return "mark done"
}
if fl.UserFacingPct < 100 && fl.UserFacingPct > 0 {
return fmt.Sprintf("DoD user-facing %d%%", fl.UserFacingPct)
}
if fl.AcceptancePct < 100 {
return fmt.Sprintf("acceptance %d%%", fl.AcceptancePct)
}
return "check DoD"
}
// workDashboard prints a JSON dashboard for the work tab.
func workDashboard(flags Flags) {
root := mustRegistryRoot()
issues, err := LoadAllIssues(root)
if err != nil {
fatalf("load issues: %v", err)
}
ComputeDepsResolved(issues)
flows, err := LoadAllFlows(root)
if err != nil {
fatalf("load flows: %v", err)
}
// Build stats
type stats struct {
Total int `json:"total"`
Pendiente int `json:"pendiente"`
InProgress int `json:"in_progress"`
Bloqueado int `json:"bloqueado"`
Completado int `json:"completado"`
}
var ist stats
for _, iss := range issues {
ist.Total++
switch strings.ToLower(iss.Status) {
case "pendiente", "pending":
ist.Pendiente++
case "in-progress":
ist.InProgress++
case "bloqueado", "blocked":
ist.Bloqueado++
case "completado", "done", "completed":
ist.Completado++
}
}
var fst stats
for _, fl := range flows {
fst.Total++
switch strings.ToLower(fl.Status) {
case "pendiente", "pending":
fst.Pendiente++
case "in-progress":
fst.InProgress++
case "completado", "done", "completed":
fst.Completado++
}
}
// Top priority issues
var topIssues []Issue
for _, iss := range issues {
if iss.Priority == "alta" {
st := strings.ToLower(iss.Status)
if st != "completado" && st != "done" && st != "completed" && st != "deferred" {
topIssues = append(topIssues, iss)
}
}
}
sort.Slice(topIssues, func(i, j int) bool {
return topIssues[i].ID < topIssues[j].ID
})
if len(topIssues) > 10 {
topIssues = topIssues[:10]
}
type issueSlim struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"type"`
Domain []string `json:"domain"`
Priority string `json:"priority"`
Depends []string `json:"depends"`
DepsResolved bool `json:"deps_resolved"`
AcceptancePct int `json:"acceptance_pct"`
}
type flowSlim struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Pattern string `json:"pattern"`
Risk string `json:"risk"`
Priority string `json:"priority"`
Apps []string `json:"apps"`
AcceptancePct int `json:"acceptance_pct"`
DoDPct int `json:"dod_pct"`
UserFacingPct int `json:"user_facing_pct"`
}
slimIssues := make([]issueSlim, 0, len(topIssues))
for _, iss := range topIssues {
slimIssues = append(slimIssues, issueSlim{
ID: iss.ID, Title: iss.Title, Status: iss.Status, Type: iss.Type,
Domain: iss.Domain, Priority: iss.Priority, Depends: iss.Depends,
DepsResolved: iss.DepsResolved, AcceptancePct: iss.AcceptancePct,
})
}
slimFlows := make([]flowSlim, 0, len(flows))
for _, fl := range flows {
slimFlows = append(slimFlows, flowSlim{
ID: fl.ID, Name: fl.Name, Status: fl.Status, Pattern: fl.Pattern,
Risk: fl.Risk, Priority: fl.Priority, Apps: fl.Apps,
AcceptancePct: fl.AcceptancePct, DoDPct: fl.DoDPct, UserFacingPct: fl.UserFacingPct,
})
}
out := map[string]any{
"issue_stats": ist,
"flow_stats": fst,
"top_issues": slimIssues,
"flows": slimFlows,
}
printJSON(out)
}