Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Compatible avec** : OpenAI Codex CLI · Mistral Vibe · Google Gemini CLI (voir aussi `GEMINI.md`)
>
> ulk est un monorepo de **94 agents IA spécialisés** pour Claude Code, portable vers d'autres CLI LLM.
> ulk est un monorepo de **98 agents IA spécialisés** pour Claude Code, portable vers d'autres CLI LLM.
> Ce fichier fournit le contexte projet pour que l'agent puisse travailler efficacement dans ce repo.

## Qu'est-ce qu'ulk ?
Expand All @@ -12,7 +12,7 @@ ulk est une boîte à outils de développement assisté par IA — une collectio
**Structure principale :**
```
framework/
├── agents/ # 94 agents .md avec frontmatter YAML
├── agents/ # 98 agents .md avec frontmatter YAML
│ ├── _shared/ # Protocoles partagés (base-rules, context-hygiene, etc.)
│ ├── orchestrators/ # Bruce, Blackemperor, Tony, Mathieu, Lovecraft
│ ├── audit/ # Sargeras, ED-209, Harper, Vision, Perf, SEO, A11y
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

**ulk** is an AI-assisted development toolkit — a monorepo of 95 specialized AI agents for Claude Code.
**ulk** is an AI-assisted development toolkit — a monorepo of 98 specialized AI agents for Claude Code.

- **framework/agents/** — Agent definitions (custom commands)
- **framework/packages/core** — Shared TypeScript library (parser, types, GitHub client)
Expand Down
4 changes: 2 additions & 2 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

## Présentation

ulk est un monorepo de **94 agents IA spécialisés**, architecturé autour de Claude Code et portable vers Gemini CLI via ce fichier + les sous-agents dans `.gemini/agents/`.
ulk est un monorepo de **98 agents IA spécialisés**, architecturé autour de Claude Code et portable vers Gemini CLI via ce fichier + les sous-agents dans `.gemini/agents/`.

Gemini CLI reconnaît automatiquement ce fichier `GEMINI.md` ainsi que les sous-agents dans `.gemini/agents/<name>.md`.

## Structure du projet

```
framework/agents/ # 94 agents source (format Claude Code)
framework/agents/ # 98 agents source (format Claude Code)
_shared/ # Protocoles partagés
orchestrators/ # Bruce, Blackemperor, Tony, Mathieu
audit/ # Sargeras, ED-209, Harper, Vision
Expand Down
4 changes: 2 additions & 2 deletions framework/agents/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this directory.

## Overview

94 specialized AI agent definitions for the ulk toolkit. Each agent is a Markdown file with YAML frontmatter defining a focused, autonomous sub-agent for Claude Code.
98 specialized AI agent definitions for the ulk toolkit. Each agent is a Markdown file with YAML frontmatter defining a focused, autonomous sub-agent for Claude Code.

## Agent Definition Structure

Expand All @@ -26,7 +26,7 @@ Full spec: `.claude/rules/agents-authoring.md`

> Auto-generated — do not edit manually.

- **Machine-readable**: `registry.json` — 94 agents, full frontmatter
- **Machine-readable**: `registry.json` — 98 agents, full frontmatter
- **Human-readable**: `registry.md` — table by category
- **Regenerate**: `node framework/cheatheet/generate-registry.cjs`

Expand Down
207 changes: 207 additions & 0 deletions framework/cli/cmd/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"

"github.com/izo/ulk/internal/registry"
"github.com/spf13/cobra"
)

func init() {
rootCmd.AddCommand(agentsCmd)
agentsCmd.AddCommand(agentsStatsCmd)
agentsStatsCmd.Flags().Bool("json", false, "Output as JSON")
agentsStatsCmd.Flags().Bool("usage", false, "Overlay best-effort runtime usage from .ulk-reports/accountability.jsonl")
agentsStatsCmd.Flags().String("source", "", "Path to the ulk source dir (default: auto-detect)")
}

var agentsCmd = &cobra.Command{
Use: "agents",
Short: "Inspect the agent registry (composition, count reconciliation, usage)",
}

var agentsStatsCmd = &cobra.Command{
Use: "stats",
Short: "Show agent registry composition and reconcile declared counts",
Long: `Reads framework/agents/registry.json (the source of truth) and reports the
agent composition by category, model and phase. Reconciles the count declared
in CLAUDE.md / AGENTS.md against the registry to surface drift.

With --usage, overlays best-effort runtime usage from
.ulk-reports/accountability.jsonl. Attribution is incomplete by design until
the accountability protocol auto-propagates the active agent, so the overlay
reports its attribution coverage and is NEVER proof that an agent is unused.

Examples:
ulk agents stats
ulk agents stats --json
ulk agents stats --usage`,
RunE: runAgentsStats,
}

// reconcileTargets are docs that declare an agent count and tend to drift.
// Paths are relative to the ulk source dir.
var reconcileTargets = []string{
"framework/agents/registry.md",
"CLAUDE.md",
"AGENTS.md",
"framework/agents/CLAUDE.md",
}

// declaredCountRe captures a 2-3 digit count immediately preceding "agent(s)",
// optionally via "specialized [AI] agent". Matches "95 specialized AI agents",
// "94 agents IA", "98 agents", "94 specialized AI agent definitions".
var declaredCountRe = regexp.MustCompile(`(?i)(\d{2,3})\s+(?:specialized\s+(?:ai\s+)?agents?|agents?)`)

func runAgentsStats(cmd *cobra.Command, _ []string) error {
jsonOut, _ := cmd.Flags().GetBool("json")
withUsage, _ := cmd.Flags().GetBool("usage")
explicitSrc, _ := cmd.Flags().GetString("source")

src, err := resolveSourceDir(explicitSrc)
if err != nil {
return fmt.Errorf("cannot locate ulk source: %w\nSet ULK_SOURCE or use --source", err)
}

reg, err := registry.LoadAgentRegistry(filepath.Join(src, "framework", "agents", "registry.json"))
if err != nil {
return fmt.Errorf("cannot load agent registry: %w", err)
}
stats := reg.Stats()

type reconLine struct {
Path string `json:"path"`
Declared int `json:"declared"`
Found bool `json:"found"`
OK bool `json:"ok"`
}
recon := make([]reconLine, 0, len(reconcileTargets))
for _, rel := range reconcileTargets {
n, found := declaredAgentCount(filepath.Join(src, rel))
recon = append(recon, reconLine{Path: rel, Declared: n, Found: found, OK: found && n == stats.Total})
}

var usage *registry.AgentUsage
if withUsage {
usage, err = registry.LoadAgentUsage(filepath.Join(src, ".ulk-reports", "accountability.jsonl"))
if err != nil {
return fmt.Errorf("cannot read accountability journal: %w", err)
}
}

if jsonOut {
out := map[string]any{
"total": stats.Total,
"count_field_ok": stats.CountFieldOK,
"by_category": stats.ByCategory,
"by_model": stats.ByModel,
"by_phase": stats.ByPhase,
"reconciliation": recon,
}
if usage != nil {
out["usage"] = usage
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}

fmt.Printf("🤖 AGENT REGISTRY — stats\n\n")
countMark := "✓"
if !stats.CountFieldOK {
countMark = fmt.Sprintf("✗ registry count field says %d", stats.DeclaredCount)
}
fmt.Printf(" Total agents : %d (registry count field: %s)\n\n", stats.Total, countMark)

printBucket("By category", stats.ByCategory)
printBucket("By model", stats.ByModel)
printBucket("By phase", stats.ByPhase)

fmt.Printf(" Count reconciliation (source of truth = registry: %d)\n", stats.Total)
for _, r := range recon {
switch {
case !r.Found:
fmt.Printf(" %-32s (no count declared)\n", r.Path)
case r.OK:
fmt.Printf(" %-32s %d ✓\n", r.Path, r.Declared)
default:
fmt.Printf(" %-32s %d ✗ (drift %+d)\n", r.Path, r.Declared, r.Declared-stats.Total)
}
}
fmt.Println()

printUsage(withUsage, usage)
return nil
}

func printBucket(title string, m map[string]int) {
fmt.Printf(" %s\n", title)
for _, kv := range sortedByValueDesc(m) {
fmt.Printf(" %-16s %d\n", kv.key, kv.val)
}
fmt.Println()
}

func printUsage(enabled bool, u *registry.AgentUsage) {
if !enabled {
fmt.Printf(" Usage overlay : disabled (pass --usage to derive from accountability.jsonl)\n")
return
}
if u == nil || !u.Available {
fmt.Printf(" Usage overlay : .ulk-reports/accountability.jsonl not found — run `ulk install --with-accountability`\n")
return
}
fmt.Printf(" Usage overlay (best-effort — attribution %.0f%% complete; NOT proof of disuse)\n",
u.AttributionRatio*100)
fmt.Printf(" mutations %d · attributed %d · unknown %d\n", u.TotalMutations, u.Attributed, u.Unknown)
top := sortedByValueDesc(u.ByAgent)
if len(top) > 10 {
top = top[:10]
}
for _, kv := range top {
fmt.Printf(" %-16s %d\n", kv.key, kv.val)
}
}

type kvPair struct {
key string
val int
}

// sortedByValueDesc returns map entries sorted by value desc, then key asc.
func sortedByValueDesc(m map[string]int) []kvPair {
pairs := make([]kvPair, 0, len(m))
for k, v := range m {
pairs = append(pairs, kvPair{k, v})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].val != pairs[j].val {
return pairs[i].val > pairs[j].val
}
return pairs[i].key < pairs[j].key
})
return pairs
}

// declaredAgentCount extracts the first declared agent count from a file.
// Returns (0, false) if the file is unreadable or declares no count.
func declaredAgentCount(path string) (int, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
m := declaredCountRe.FindSubmatch(data)
if m == nil {
return 0, false
}
n := 0
for _, c := range m[1] {
n = n*10 + int(c-'0')
}
return n, true
}
57 changes: 57 additions & 0 deletions framework/cli/cmd/agents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cmd

import (
"os"
"path/filepath"
"testing"
)

func TestDeclaredAgentCount(t *testing.T) {
cases := []struct {
name string
content string
want int
found bool
}{
{"claude-md", "**ulk** is a monorepo of 95 specialized AI agents for Claude Code.", 95, true},
{"agents-md-fr", "ulk est un monorepo de 94 agents IA spécialisés", 94, true},
{"registry-md", "**98 agents** — machine-readable: `agents/registry.json`", 98, true},
{"agents-claude-md", "94 specialized AI agent definitions for the ulk toolkit.", 94, true},
{"no-count", "This file declares no agent count at all.", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "doc.md")
if err := os.WriteFile(p, []byte(c.content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, found := declaredAgentCount(p)
if found != c.found {
t.Fatalf("found = %v, want %v", found, c.found)
}
if got != c.want {
t.Errorf("count = %d, want %d", got, c.want)
}
})
}
}

func TestDeclaredAgentCountMissingFile(t *testing.T) {
if _, found := declaredAgentCount(filepath.Join(t.TempDir(), "nope.md")); found {
t.Errorf("found = true, want false for missing file")
}
}

func TestSortedByValueDesc(t *testing.T) {
got := sortedByValueDesc(map[string]int{"a": 1, "b": 3, "c": 3})
// value desc, then key asc → b(3), c(3), a(1)
want := []kvPair{{"b", 3}, {"c", 3}, {"a", 1}}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] = %v, want %v", i, got[i], want[i])
}
}
}
Loading