claude-smart 0.2.30 → 0.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +172 -18
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +2 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +20 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/hooks/hooks.json +1 -1
  43. package/plugin/pyproject.toml +1 -1
  44. package/plugin/scripts/_lib.sh +126 -0
  45. package/plugin/scripts/backend-service.sh +32 -7
  46. package/plugin/scripts/cli.sh +4 -2
  47. package/plugin/scripts/codex-hook.js +100 -3
  48. package/plugin/scripts/dashboard-service.sh +98 -19
  49. package/plugin/scripts/hook_entry.sh +32 -11
  50. package/plugin/scripts/smart-install.sh +27 -44
  51. package/plugin/src/claude_smart/cli.py +204 -20
  52. package/plugin/src/claude_smart/context_format.py +244 -6
  53. package/plugin/src/claude_smart/context_inject.py +8 -1
  54. package/plugin/src/claude_smart/cs_cite.py +186 -34
  55. package/plugin/src/claude_smart/env_config.py +102 -0
  56. package/plugin/src/claude_smart/events/session_end.py +171 -6
  57. package/plugin/src/claude_smart/events/session_start.py +26 -2
  58. package/plugin/src/claude_smart/events/stop.py +48 -9
  59. package/plugin/src/claude_smart/hook.py +62 -4
  60. package/plugin/src/claude_smart/hook_log.py +301 -0
  61. package/plugin/src/claude_smart/internal_call.py +30 -0
  62. package/plugin/src/claude_smart/publish.py +5 -0
  63. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  64. package/plugin/uv.lock +1 -1
@@ -0,0 +1,51 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AlertTriangle, ArrowLeft } from "lucide-react";
4
+ import { PageHeader } from "@/components/common/page-header";
5
+ import { EmptyState } from "@/components/common/empty-state";
6
+ import { Button } from "@/components/ui/button";
7
+ import { resolveRuleLink } from "@/lib/session-reader";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ export default async function RuleResolverPage({
12
+ params,
13
+ }: {
14
+ params: Promise<{ id: string }>;
15
+ }) {
16
+ const { id: rawId } = await params;
17
+ let id: string;
18
+ try {
19
+ id = decodeURIComponent(rawId);
20
+ } catch {
21
+ id = rawId;
22
+ }
23
+ const resolved = await resolveRuleLink(id);
24
+ if (resolved) {
25
+ redirect(resolved.href);
26
+ }
27
+
28
+ return (
29
+ <div className="flex-1 overflow-auto">
30
+ <PageHeader
31
+ title="Rule link not found"
32
+ description="This short claude-smart link could not be resolved from the local session registry."
33
+ />
34
+ <div className="p-6 max-w-2xl mx-auto">
35
+ <EmptyState
36
+ icon={AlertTriangle}
37
+ title="Rule link not found"
38
+ description="The session may have been cleared, or the short link may come from a different machine."
39
+ action={
40
+ <Link href="/sessions">
41
+ <Button variant="outline" size="sm">
42
+ <ArrowLeft className="h-3.5 w-3.5" />
43
+ Back to sessions
44
+ </Button>
45
+ </Link>
46
+ }
47
+ />
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -121,7 +121,7 @@ export default function InteractionDetailPage({
121
121
  <article
122
122
  key={idx}
123
123
  className={cn(
124
- "rounded-xl border px-4 py-3 bg-card",
124
+ "rounded-lg border px-4 py-3 bg-card",
125
125
  flagged
126
126
  ? "border-destructive/30 bg-destructive/5"
127
127
  : "border-border",
@@ -177,7 +177,7 @@ export default function InteractionDetailPage({
177
177
  <div className="mt-1 space-y-1.5">
178
178
  {hasInput && (
179
179
  <div>
180
- <div className="text-[10px] font-mono uppercase tracking-wide text-muted-foreground/70 mb-0.5">
180
+ <div className="text-[10px] font-mono uppercase text-muted-foreground/70 mb-0.5">
181
181
  input
182
182
  </div>
183
183
  <pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/40 px-2 py-1 text-[10px] font-mono text-muted-foreground">
@@ -187,7 +187,7 @@ export default function InteractionDetailPage({
187
187
  )}
188
188
  {hasOutput && (
189
189
  <div>
190
- <div className="text-[10px] font-mono uppercase tracking-wide text-muted-foreground/70 mb-0.5">
190
+ <div className="text-[10px] font-mono uppercase text-muted-foreground/70 mb-0.5">
191
191
  output
192
192
  </div>
193
193
  <pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/40 px-2 py-1 text-[10px] font-mono text-muted-foreground">
@@ -241,7 +241,7 @@ function CitedItemsRow({ items }: { items: CitedItem[] }) {
241
241
  ? item.source_kind === "agent_playbook"
242
242
  ? `/skills/shared/${encodeURIComponent(targetId)}`
243
243
  : `/skills/project/${encodeURIComponent(targetId)}`
244
- : `/preferences/${encodeURIComponent(targetId)}`;
244
+ : `/preferences/project/${encodeURIComponent(targetId)}`;
245
245
  return (
246
246
  <Link
247
247
  key={item.id}
@@ -60,12 +60,12 @@ export default function SessionsPage() {
60
60
  actions={
61
61
  <div className="flex items-center gap-2">
62
62
  <div className="relative">
63
- <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
63
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
64
64
  <Input
65
65
  value={filter}
66
66
  onChange={(e) => setFilter(e.target.value)}
67
67
  placeholder="Search sessions…"
68
- className="h-8 w-64 pl-7 text-xs"
68
+ className="h-9 w-64 pl-8 text-xs bg-background/80"
69
69
  />
70
70
  </div>
71
71
  <DeleteAllButton
@@ -108,14 +108,14 @@ export default function SessionsPage() {
108
108
  {grouped.map(([label, items]) => (
109
109
  <section key={label}>
110
110
  <div className="flex items-center gap-2 mb-2 px-1">
111
- <h2 className="text-[11px] uppercase tracking-wider font-medium text-muted-foreground">
111
+ <h2 className="text-[11px] uppercase font-semibold text-muted-foreground">
112
112
  {label}
113
113
  </h2>
114
114
  <span className="text-[11px] text-muted-foreground/70">
115
115
  · {items.length}
116
116
  </span>
117
117
  </div>
118
- <div className="rounded-xl border border-border divide-y divide-border bg-card overflow-hidden">
118
+ <div className="rounded-lg border border-border divide-y divide-border bg-card/90 overflow-hidden shadow-sm">
119
119
  {items.map((s) => (
120
120
  <div
121
121
  key={s.session_id}
@@ -134,11 +134,14 @@ export default function SessionsPage() {
134
134
  );
135
135
  }
136
136
  }}
137
- className="flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-accent/40 focus:bg-accent/40 focus:outline-none transition-colors"
137
+ className="flex cursor-pointer flex-col gap-2 px-4 py-3 transition-colors hover:bg-accent/45 focus:bg-accent/45 focus:outline-none sm:flex-row sm:items-center"
138
138
  >
139
- <MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
140
- <div className="flex-1 min-w-0">
141
- <div className="flex items-center gap-2">
139
+ <div className="flex min-w-0 items-start gap-3">
140
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
141
+ <MessageSquare className="h-4 w-4" />
142
+ </span>
143
+ <div className="flex-1 min-w-0">
144
+ <div className="flex flex-wrap items-center gap-2">
142
145
  <p className="text-sm truncate">
143
146
  {s.preview ?? (
144
147
  <span className="text-muted-foreground italic">
@@ -167,10 +170,11 @@ export default function SessionsPage() {
167
170
  {s.published_up_to} published
168
171
  </span>
169
172
  </>
170
- )}
173
+ )}
174
+ </div>
171
175
  </div>
172
176
  </div>
173
- <div className="text-xs text-muted-foreground shrink-0 tabular-nums">
177
+ <div className="self-end text-xs text-muted-foreground tabular-nums sm:shrink-0">
174
178
  {formatRelative(s.last_activity)}
175
179
  </div>
176
180
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useState } from "react";
4
4
  import Link from "next/link";
5
- import { BookOpen, Layers3 } from "lucide-react";
5
+ import { BookOpen, ChevronRight, Layers3 } from "lucide-react";
6
6
  import { PageHeader } from "@/components/common/page-header";
7
7
  import { EmptyState } from "@/components/common/empty-state";
8
8
  import { DeleteAllButton } from "@/components/common/delete-all-button";
@@ -17,7 +17,6 @@ import {
17
17
  SelectValue,
18
18
  } from "@/components/ui/select";
19
19
  import { reflexio } from "@/lib/reflexio-client";
20
- import { useSettings } from "@/hooks/use-settings";
21
20
  import { formatRelative } from "@/lib/format";
22
21
  import { cn } from "@/lib/utils";
23
22
  import {
@@ -26,10 +25,17 @@ import {
26
25
  type AgentPlaybookStatusLabel,
27
26
  type StatusLabel,
28
27
  } from "@/lib/status";
29
- import type { AgentPlaybook, UserPlaybook } from "@/lib/types";
28
+ import type {
29
+ AgentPlaybook,
30
+ PlaybookApplicationStat,
31
+ UserPlaybook,
32
+ } from "@/lib/types";
30
33
 
31
34
  type SkillKind = "project" | "shared";
32
35
  type SkillStatus = StatusLabel | AgentPlaybookStatusLabel;
36
+ type SkillSort = "newest" | "applied";
37
+
38
+ const ALL_LIFECYCLE_STATUSES: (string | null)[] = [null, "pending", "archived"];
33
39
 
34
40
  const SHARED_STATUS_META: Record<
35
41
  AgentPlaybookStatusLabel,
@@ -86,27 +92,51 @@ function sharedSkill(p: AgentPlaybook): SkillCard {
86
92
  };
87
93
  }
88
94
 
95
+ function skillStatKey(skill: SkillCard): string {
96
+ const sourceKind =
97
+ skill.kind === "shared" ? "agent_playbook" : "user_playbook";
98
+ return `playbook:${sourceKind}:${skill.id}`;
99
+ }
100
+
89
101
  export default function SkillsPage() {
90
- const { reflexioUrl } = useSettings();
91
102
  const [projectSkills, setProjectSkills] = useState<UserPlaybook[] | null>(null);
92
103
  const [sharedSkills, setSharedSkills] = useState<AgentPlaybook[] | null>(null);
104
+ const [appStats, setAppStats] = useState<PlaybookApplicationStat[] | null>(
105
+ null,
106
+ );
93
107
  const [error, setError] = useState<string | null>(null);
94
108
  const [activeKind, setActiveKind] = useState<SkillKind>("project");
95
109
  const [agentVersion, setAgentVersion] = useState<string>("__all__");
96
110
  const [statusFilter, setStatusFilter] = useState<string>("CURRENT");
111
+ const [sortBy, setSortBy] = useState<SkillSort>("newest");
97
112
  const [search, setSearch] = useState("");
98
113
 
99
114
  useEffect(() => {
100
115
  let cancelled = false;
101
116
  async function load() {
102
117
  try {
103
- const [projectRes, sharedRes] = await Promise.all([
104
- reflexio.getUserPlaybooks({ reflexioUrl, limit: 200 }),
105
- reflexio.getAgentPlaybooks({ reflexioUrl, limit: 200 }),
118
+ const [projectRes, sharedRes, statsRes] = await Promise.all([
119
+ reflexio.getUserPlaybooks({
120
+ limit: 500,
121
+ statusFilter: ALL_LIFECYCLE_STATUSES,
122
+ }),
123
+ reflexio.getAgentPlaybooks({
124
+ limit: 500,
125
+ statusFilter: ALL_LIFECYCLE_STATUSES,
126
+ }),
127
+ fetch("/api/rules/applied?daysBack=30&limit=200", {
128
+ cache: "no-store",
129
+ })
130
+ .then((r) => r.json())
131
+ .catch(() => ({
132
+ success: false,
133
+ stats: [] as PlaybookApplicationStat[],
134
+ })),
106
135
  ]);
107
136
  if (cancelled) return;
108
137
  setProjectSkills(projectRes.user_playbooks ?? []);
109
138
  setSharedSkills(sharedRes.agent_playbooks ?? []);
139
+ setAppStats(statsRes.stats ?? []);
110
140
  setError(null);
111
141
  } catch (e) {
112
142
  if (!cancelled) setError(e instanceof Error ? e.message : String(e));
@@ -116,7 +146,15 @@ export default function SkillsPage() {
116
146
  return () => {
117
147
  cancelled = true;
118
148
  };
119
- }, [reflexioUrl]);
149
+ }, []);
150
+
151
+ const statsByRule = useMemo(() => {
152
+ const map = new Map<string, PlaybookApplicationStat>();
153
+ for (const s of appStats ?? []) {
154
+ map.set(`${s.kind}:${s.source_kind ?? "unknown"}:${s.real_id}`, s);
155
+ }
156
+ return map;
157
+ }, [appStats]);
120
158
 
121
159
  const activeSkills = useMemo(() => {
122
160
  return activeKind === "project"
@@ -131,7 +169,7 @@ export default function SkillsPage() {
131
169
  }, [activeSkills]);
132
170
 
133
171
  const filtered = useMemo(() => {
134
- return activeSkills.filter((p) => {
172
+ const matches = activeSkills.filter((p) => {
135
173
  if (agentVersion !== "__all__" && p.agentVersion !== agentVersion)
136
174
  return false;
137
175
  if (statusFilter !== "__all__" && p.status !== statusFilter) return false;
@@ -142,10 +180,24 @@ export default function SkillsPage() {
142
180
  }
143
181
  return true;
144
182
  });
145
- }, [activeSkills, agentVersion, statusFilter, search]);
183
+ return matches.sort((a, b) => {
184
+ if (sortBy === "applied") {
185
+ const aStat = statsByRule.get(skillStatKey(a));
186
+ const bStat = statsByRule.get(skillStatKey(b));
187
+ const appliedDelta =
188
+ (bStat?.applied_count ?? 0) - (aStat?.applied_count ?? 0);
189
+ if (appliedDelta !== 0) return appliedDelta;
190
+ const recencyDelta =
191
+ (bStat?.last_applied_at ?? 0) - (aStat?.last_applied_at ?? 0);
192
+ if (recencyDelta !== 0) return recencyDelta;
193
+ }
194
+ return b.createdAt - a.createdAt;
195
+ });
196
+ }, [activeSkills, agentVersion, search, sortBy, statsByRule, statusFilter]);
146
197
 
147
198
  const projectCount = projectSkills?.length ?? 0;
148
199
  const sharedCount = sharedSkills?.length ?? 0;
200
+ const visibleActiveCount = filtered.length;
149
201
  const activeCount = activeKind === "project" ? projectCount : sharedCount;
150
202
  const loading = projectSkills === null || sharedSkills === null;
151
203
  const hasNoSharedSkills = activeKind === "shared" && sharedCount === 0;
@@ -167,7 +219,7 @@ export default function SkillsPage() {
167
219
  value={agentVersion}
168
220
  onValueChange={(v) => setAgentVersion(v ?? "__all__")}
169
221
  >
170
- <SelectTrigger size="sm" className="w-40 text-xs">
222
+ <SelectTrigger size="sm" className="w-40 text-xs bg-background/80">
171
223
  <SelectValue placeholder="Project" />
172
224
  </SelectTrigger>
173
225
  <SelectContent>
@@ -183,8 +235,10 @@ export default function SkillsPage() {
183
235
  value={statusFilter}
184
236
  onValueChange={(v) => setStatusFilter(v ?? "__all__")}
185
237
  >
186
- <SelectTrigger size="sm" className="w-36 text-xs">
187
- <SelectValue placeholder="Status" />
238
+ <SelectTrigger size="sm" className="w-36 text-xs bg-background/80">
239
+ <SelectValue placeholder="Status">
240
+ {statusFilterLabel(activeKind, statusFilter)}
241
+ </SelectValue>
188
242
  </SelectTrigger>
189
243
  <SelectContent>
190
244
  <SelectItem value="__all__">All</SelectItem>
@@ -218,11 +272,25 @@ export default function SkillsPage() {
218
272
  )}
219
273
  </SelectContent>
220
274
  </Select>
275
+ <Select
276
+ value={sortBy}
277
+ onValueChange={(v) => setSortBy((v as SkillSort) ?? "newest")}
278
+ >
279
+ <SelectTrigger size="sm" className="w-36 text-xs bg-background/80">
280
+ <SelectValue placeholder="Sort">
281
+ {sortBy === "applied" ? "Most applied" : "Newest"}
282
+ </SelectValue>
283
+ </SelectTrigger>
284
+ <SelectContent>
285
+ <SelectItem value="newest">Newest</SelectItem>
286
+ <SelectItem value="applied">Most applied</SelectItem>
287
+ </SelectContent>
288
+ </Select>
221
289
  <Input
222
290
  value={search}
223
291
  onChange={(e) => setSearch(e.target.value)}
224
292
  placeholder="Search"
225
- className="h-8 w-48 text-xs"
293
+ className="h-9 w-48 text-xs bg-background/80"
226
294
  />
227
295
  <DeleteAllButton
228
296
  label={`Delete ${activeKind === "project" ? "project" : "shared"}${activeCount > 0 ? ` (${activeCount})` : ""}`}
@@ -230,10 +298,10 @@ export default function SkillsPage() {
230
298
  disabled={activeCount === 0}
231
299
  onConfirm={async () => {
232
300
  if (activeKind === "project") {
233
- await reflexio.deleteAllUserPlaybooks(reflexioUrl);
301
+ await reflexio.deleteAllUserPlaybooks();
234
302
  setProjectSkills([]);
235
303
  } else {
236
- await reflexio.deleteAllAgentPlaybooks(reflexioUrl);
304
+ await reflexio.deleteAllAgentPlaybooks();
237
305
  setSharedSkills([]);
238
306
  }
239
307
  }}
@@ -251,14 +319,14 @@ export default function SkillsPage() {
251
319
  id: "project",
252
320
  label: "Project-specific skills",
253
321
  description: "Repo-local rules learned from direct corrections",
254
- count: projectCount,
322
+ count: activeKind === "project" ? visibleActiveCount : projectCount,
255
323
  icon: BookOpen,
256
324
  },
257
325
  {
258
326
  id: "shared",
259
327
  label: "Shared skills",
260
328
  description: "Rollups available across projects",
261
- count: sharedCount,
329
+ count: activeKind === "shared" ? visibleActiveCount : sharedCount,
262
330
  icon: Layers3,
263
331
  },
264
332
  ]}
@@ -266,7 +334,7 @@ export default function SkillsPage() {
266
334
 
267
335
  {error && (
268
336
  <div className="rounded-lg border border-destructive/30 bg-destructive/5 text-destructive px-4 py-3 text-sm">
269
- {error}. Is reflexio running on the URL in the top bar?
337
+ {error}. Is reflexio running on the configured backend URL?
270
338
  </div>
271
339
  )}
272
340
 
@@ -287,45 +355,60 @@ export default function SkillsPage() {
287
355
  }
288
356
  />
289
357
  ) : (
290
- <div className="grid gap-3 lg:grid-cols-2">
291
- {filtered.map((p) => (
292
- <Link
293
- key={`${p.kind}:${p.id}`}
294
- href={`/skills/${p.kind}/${p.id}`}
295
- className="block rounded-xl border border-border bg-card p-4 hover:bg-accent/40 transition-colors"
296
- >
297
- <header className="flex items-center justify-between gap-2 mb-2">
298
- <div className="flex items-center gap-2 min-w-0">
299
- <Badge variant="outline" className="h-5 font-mono text-[10px]">
300
- {p.agentVersion}
301
- </Badge>
302
- <StatusBadge kind={p.kind} status={p.status} />
303
- <Badge variant="secondary" className="h-5 text-[10px]">
304
- {p.kind === "project" ? "project-specific" : "shared"}
305
- </Badge>
306
- </div>
307
- <span className="text-[11px] text-muted-foreground shrink-0">
308
- {formatRelative(p.createdAt)}
309
- </span>
310
- </header>
311
- <p
312
- className={cn(
313
- "text-sm leading-relaxed line-clamp-4",
314
- !p.trigger && "text-muted-foreground italic",
315
- )}
358
+ <div className="overflow-hidden rounded-lg border border-border bg-card/92 shadow-sm">
359
+ {filtered.map((p) => {
360
+ const stat = statsByRule.get(skillStatKey(p));
361
+ return (
362
+ <Link
363
+ key={`${p.kind}:${p.id}`}
364
+ href={`/skills/${p.kind}/${p.id}`}
365
+ className="group block border-b border-border px-4 py-3.5 transition-colors last:border-b-0 hover:bg-accent/35"
316
366
  >
317
- {p.trigger || "Always applies"}
318
- </p>
319
- <p className="text-xs text-muted-foreground mt-2 line-clamp-2">
320
- <span className="font-medium">Rule:</span> {p.content}
321
- </p>
322
- {p.rationale && (
323
- <p className="text-xs text-muted-foreground mt-1 line-clamp-2">
324
- <span className="font-medium">Why:</span> {p.rationale}
367
+ <header className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
368
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
369
+ <Badge
370
+ variant="outline"
371
+ className="h-5 max-w-56 truncate font-mono text-[10px]"
372
+ >
373
+ {p.agentVersion}
374
+ </Badge>
375
+ <StatusBadge kind={p.kind} status={p.status} />
376
+ <Badge variant="secondary" className="h-5 text-[10px]">
377
+ {p.kind === "project" ? "project-specific" : "shared"}
378
+ </Badge>
379
+ <ApplicationStatBadge stat={stat} />
380
+ </div>
381
+ <div className="flex shrink-0 items-center gap-1.5 pt-0.5">
382
+ <span className="text-[11px] text-muted-foreground">
383
+ {formatRelative(p.createdAt)}
384
+ </span>
385
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60 transition-colors group-hover:text-foreground" />
386
+ </div>
387
+ </header>
388
+ <p
389
+ className={cn(
390
+ "max-w-5xl text-sm leading-relaxed line-clamp-3",
391
+ !p.trigger && "text-muted-foreground italic",
392
+ )}
393
+ >
394
+ <span className="mr-2 align-baseline text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
395
+ Trigger
396
+ </span>
397
+ {p.trigger || "Always applies"}
325
398
  </p>
326
- )}
327
- </Link>
328
- ))}
399
+ <p className="mt-2 max-w-5xl text-xs leading-relaxed text-muted-foreground line-clamp-2">
400
+ <span className="font-medium text-foreground/80">Rule:</span>{" "}
401
+ {p.content}
402
+ </p>
403
+ {p.rationale && (
404
+ <p className="mt-1 max-w-5xl text-xs leading-relaxed text-muted-foreground line-clamp-2">
405
+ <span className="font-medium text-foreground/80">Why:</span>{" "}
406
+ {p.rationale}
407
+ </p>
408
+ )}
409
+ </Link>
410
+ );
411
+ })}
329
412
  </div>
330
413
  )}
331
414
  </div>
@@ -333,6 +416,42 @@ export default function SkillsPage() {
333
416
  );
334
417
  }
335
418
 
419
+ function ApplicationStatBadge({ stat }: { stat: PlaybookApplicationStat | undefined }) {
420
+ if (!stat || stat.applied_count === 0) {
421
+ return (
422
+ <Badge
423
+ variant="outline"
424
+ className="h-5 text-[10px] text-muted-foreground"
425
+ title="No citations recorded yet for this rule. It will count once an assistant reply cites it."
426
+ >
427
+ Never applied
428
+ </Badge>
429
+ );
430
+ }
431
+ const last = formatRelative(stat.last_applied_at);
432
+ return (
433
+ <Badge
434
+ variant="secondary"
435
+ className="h-5 text-[10px]"
436
+ title={`Last applied ${last}`}
437
+ >
438
+ Applied {stat.applied_count}×{stat.last_applied_at ? ` · ${last}` : ""}
439
+ </Badge>
440
+ );
441
+ }
442
+
443
+ function statusFilterLabel(kind: SkillKind, status: string): string {
444
+ if (status === "__all__") return "All";
445
+ if (kind === "shared") {
446
+ const meta = SHARED_STATUS_META[status as AgentPlaybookStatusLabel];
447
+ if (meta) return meta.label;
448
+ }
449
+ if (status === "CURRENT") return "Current";
450
+ if (status === "PENDING") return "Pending";
451
+ if (status === "ARCHIVED") return "Archived";
452
+ return status;
453
+ }
454
+
336
455
  function StatusBadge({
337
456
  kind,
338
457
  status,