feat: add Skill Workshop demo view

This commit is contained in:
Shakker
2026-05-31 12:39:42 +01:00
committed by Shakker
parent 0de60cec12
commit b12724b79b
5 changed files with 806 additions and 5 deletions

View File

@@ -199,14 +199,19 @@ const NEW_CHAT_SESSIONS_LOADING_MESSAGE =
const NEW_CHAT_CREATE_FAILED_MESSAGE =
"New Chat could not create a new session. Try again in a moment.";
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
export function renderTab(
state: AppViewState,
tab: Tab,
opts?: { collapsed?: boolean; child?: boolean },
) {
const href = pathForTab(tab, state.basePath);
const isActive = tab === "config" ? isSettingsTab(state.tab) : state.tab === tab;
const collapsed = opts?.collapsed ?? state.settings.navCollapsed;
const isChild = opts?.child === true;
return html`
<a
href=${href}
class="nav-item ${isActive ? "nav-item--active" : ""}"
class="nav-item ${isActive ? "nav-item--active" : ""} ${isChild ? "nav-item--child" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||

View File

@@ -153,6 +153,8 @@ import {
pathForTab,
SETTINGS_TABS,
TAB_GROUPS,
childTabsOf,
isChildTab,
subtitleForTab,
titleForTab,
type Tab,
@@ -444,6 +446,10 @@ const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewC
const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged);
const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged);
const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged);
const lazySkillWorkshop = createLazyView(
() => import("./views/skill-workshop.ts"),
notifyLazyViewChanged,
);
const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged);
export function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
@@ -1911,9 +1917,14 @@ export function renderApp(state: AppViewState) {
`
: nothing}
<div class="nav-section__items">
${group.tabs.map((tab) =>
renderTab(state, tab, { collapsed: navCollapsed }),
)}
${group.tabs
.filter((tab) => !isChildTab(tab))
.flatMap((tab) => [
renderTab(state, tab, { collapsed: navCollapsed }),
...childTabsOf(tab).map((child) =>
renderTab(state, child, { collapsed: navCollapsed, child: true }),
),
])}
</div>
</section>
`;
@@ -2846,6 +2857,43 @@ export function renderApp(state: AppViewState) {
}),
)
: nothing}
${state.tab === "skillWorkshop"
? renderLazyView(lazySkillWorkshop, (m) => {
const proposals = m.getDemoSkillWorkshopProposals();
const counts = m.countProposals(proposals);
const selectedKey = state.skillWorkshopSelectedKey ?? proposals[0]?.key ?? null;
const currentIndex = proposals.findIndex((p) => p.key === selectedKey);
const goto = (offset: number) => {
if (proposals.length === 0) return;
const idx = currentIndex < 0 ? 0 : currentIndex;
const next = (idx + offset + proposals.length) % proposals.length;
state.skillWorkshopSelectedKey = proposals[next].key;
};
return m.renderSkillWorkshop({
loading: false,
proposals,
selectedKey,
statusFilter: state.skillWorkshopStatusFilter,
query: state.skillWorkshopQuery,
filePreviewKey: state.skillWorkshopFilePreviewKey,
counts,
onStatusFilterChange: (next) => (state.skillWorkshopStatusFilter = next),
onQueryChange: (next) => (state.skillWorkshopQuery = next),
onSelect: (key) => {
state.skillWorkshopSelectedKey = key;
state.skillWorkshopFilePreviewKey = null;
},
onPrev: () => goto(-1),
onNext: () => goto(1),
onApply: () => {},
onRevise: () => {},
onSetAside: () => {},
onReject: () => {},
onPreviewFile: (_key, path) => (state.skillWorkshopFilePreviewKey = path),
onClosePreview: () => (state.skillWorkshopFilePreviewKey = null),
});
})
: nothing}
${state.tab === "nodes"
? renderLazyView(lazyNodes, (m) =>
m.renderNodes({

View File

@@ -425,6 +425,10 @@ export type AppViewState = {
skillCardContentKeys: Record<string, string>;
skillCardLoadingKey: string | null;
skillCardErrors: Record<string, string>;
skillWorkshopSelectedKey: string | null;
skillWorkshopStatusFilter: "all" | "pending" | "applied" | "rejected" | "quarantined" | "stale";
skillWorkshopQuery: string;
skillWorkshopFilePreviewKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;

View File

@@ -622,6 +622,17 @@ export class OpenClawApp extends LitElement {
@state() skillCardLoadingKey: string | null = null;
@state() skillCardErrors: Record<string, string> = {};
@state() skillWorkshopSelectedKey: string | null = null;
@state() skillWorkshopStatusFilter:
| "all"
| "pending"
| "applied"
| "rejected"
| "quarantined"
| "stale" = "all";
@state() skillWorkshopQuery = "";
@state() skillWorkshopFilePreviewKey: string | null = null;
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;
@state() healthError: string | null = null;

View File

@@ -0,0 +1,733 @@
import { html, nothing } from "lit";
export type SkillWorkshopProposalStatus =
| "pending"
| "applied"
| "rejected"
| "quarantined"
| "stale";
export type SkillWorkshopFile = {
path: string;
size: string;
contents: string;
};
export type SkillWorkshopProposal = {
key: string;
slug: string;
name: string;
oneLine: string;
body: string;
status: SkillWorkshopProposalStatus;
version: number;
createdAt: number;
updatedAt?: number;
recencyGroup: "today" | "yesterday" | "earlier";
ageLabel: string;
supportFiles: SkillWorkshopFile[];
isNew: boolean;
};
export type SkillWorkshopStatusFilter = "all" | SkillWorkshopProposalStatus;
export type SkillWorkshopProps = {
loading: boolean;
proposals: SkillWorkshopProposal[];
selectedKey: string | null;
statusFilter: SkillWorkshopStatusFilter;
query: string;
filePreviewKey: string | null;
counts: Record<SkillWorkshopStatusFilter, number>;
onStatusFilterChange: (status: SkillWorkshopStatusFilter) => void;
onQueryChange: (query: string) => void;
onSelect: (key: string) => void;
onPrev: () => void;
onNext: () => void;
onApply: (key: string) => void;
onRevise: (key: string) => void;
onSetAside: (key: string) => void;
onReject: (key: string) => void;
onPreviewFile: (key: string, path: string) => void;
onClosePreview: () => void;
};
const STATUS_TABS: SkillWorkshopStatusFilter[] = [
"all",
"pending",
"applied",
"rejected",
"quarantined",
"stale",
];
const STATUS_LABEL: Record<SkillWorkshopStatusFilter, string> = {
all: "All",
pending: "Pending",
applied: "Applied",
rejected: "Rejected",
quarantined: "Quarantined",
stale: "Stale",
};
const GROUP_LABEL: Record<SkillWorkshopProposal["recencyGroup"], string> = {
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier this week",
};
export function renderSkillWorkshop(props: SkillWorkshopProps) {
const filtered = filterProposals(props.proposals, props.statusFilter, props.query);
const selected = filtered.find((p) => p.key === props.selectedKey) ?? filtered[0];
const groups = groupByRecency(filtered);
const preview =
selected && props.filePreviewKey
? selected.supportFiles.find((f) => f.path === props.filePreviewKey)
: null;
return html`
<section class="skill-workshop">
${renderLifecycleTabs(props)}
<div class="sw-triage">
${renderQueue(props, groups, selected)}
${selected ? renderDetail(props, selected) : renderEmpty()}
</div>
</section>
${preview && selected ? renderFilePreview(selected, preview, props.onClosePreview) : nothing}
`;
}
function renderLifecycleTabs(props: SkillWorkshopProps) {
return html`
<div class="sw-lifecycle-tabs">
${STATUS_TABS.map((status) => {
const isActive = props.statusFilter === status;
const count = props.counts[status] ?? 0;
return html`
<button
class="sw-lifecycle-tab ${isActive ? "is-active" : ""}"
@click=${() => props.onStatusFilterChange(status)}
>
${STATUS_LABEL[status]} <span class="sw-lifecycle-tab__count">${count}</span>
</button>
`;
})}
</div>
`;
}
function renderQueue(
props: SkillWorkshopProps,
groups: Array<{ label: string; items: SkillWorkshopProposal[] }>,
selected: SkillWorkshopProposal | undefined,
) {
const total = groups.reduce((sum, g) => sum + g.items.length, 0);
return html`
<aside class="sw-queue">
<div class="sw-queue__search">
<input
placeholder="Search proposals… (/)"
.value=${props.query}
@input=${(event: Event) =>
props.onQueryChange((event.target as HTMLInputElement).value ?? "")}
/>
</div>
<div class="sw-queue__body">
${total === 0
? html`<div class="sw-queue__empty">${queueEmptyText(props)}</div>`
: groups.map(
(group) => html`
<div class="sw-queue__group">
${group.label} <span class="sw-queue__group-pill">${group.items.length}</span>
</div>
${group.items.map((proposal) => renderRow(props, proposal, selected))}
`,
)}
</div>
</aside>
`;
}
function renderRow(
props: SkillWorkshopProps,
proposal: SkillWorkshopProposal,
selected: SkillWorkshopProposal | undefined,
) {
const isSelected = selected?.key === proposal.key;
const noveltyClass = proposal.isNew ? "is-new" : "is-seen";
return html`
<button
class="sw-row ${noveltyClass} ${isSelected ? "is-selected" : ""}"
@click=${() => props.onSelect(proposal.key)}
>
<span class="sw-row__dot"></span>
<span>
<span class="sw-row__title">${proposal.name}</span>
<span class="sw-row__desc">${proposal.oneLine}</span>
</span>
<span class="sw-row__meta">${proposal.ageLabel}</span>
</button>
`;
}
function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal) {
const createdLabel = proposal.updatedAt
? `Edited ${formatRelative(proposal.updatedAt)}`
: `Created ${formatRelative(proposal.createdAt)}`;
return html`
<div class="sw-detail">
<div class="sw-detail__head">
<div class="sw-detail__head-left">
<h1 class="sw-detail__title">${proposal.name}</h1>
<div class="sw-detail__one-line">${proposal.oneLine}</div>
<div class="sw-detail__meta">
<span>${createdLabel}</span>
<span>·</span>
<span>v${proposal.version}</span>
<span>·</span>
<span>${proposal.supportFiles.length} support files</span>
</div>
</div>
<div class="sw-detail__nav">
<button title="Previous (k)" @click=${props.onPrev}>↑</button>
<button title="Next (j)" @click=${props.onNext}>↓</button>
</div>
</div>
<div class="sw-detail__body">
<div class="sw-body-card">
<h1>${proposal.slug}</h1>
${renderProposalBody(proposal.body)}
</div>
${proposal.supportFiles.length > 0
? html`
<div class="sw-section" style="margin-top: 18px;">
<h3 class="sw-section__label">Support files</h3>
<div class="sw-files">
${proposal.supportFiles.map(
(file) => html`
<button
class="sw-file"
@click=${() => props.onPreviewFile(proposal.key, file.path)}
>
<span>📄</span>
<span class="sw-file__name">${file.path}</span>
<span class="sw-file__size"
>${file.size} <span class="sw-file__hint">· click to preview</span></span
>
</button>
`,
)}
</div>
</div>
`
: nothing}
</div>
<div class="sw-action-bar">
<button class="sw-btn sw-btn--primary" @click=${() => props.onApply(proposal.key)}>
Apply <span class="sw-kbd">↵</span>
</button>
<button class="sw-btn" @click=${() => props.onRevise(proposal.key)}>
Revise <span class="sw-kbd">E</span>
</button>
<button class="sw-btn sw-btn--ghost" @click=${() => props.onSetAside(proposal.key)}>
Set aside <span class="sw-kbd">⇧Q</span>
</button>
<button
class="sw-btn sw-btn--ghost sw-btn--danger"
@click=${() => props.onReject(proposal.key)}
>
Reject <span class="sw-kbd">R</span>
</button>
<span class="sw-action-bar__spacer"></span>
<div class="sw-action-bar__hint">
<span><code>J</code> next</span>
<span><code>K</code> prev</span>
<span><code>X</code> multi-select</span>
<span><code>?</code> shortcuts</span>
</div>
</div>
</div>
`;
}
function renderEmpty() {
return html`
<div class="sw-detail sw-detail--empty">
<p class="sw-empty__title">No proposals match</p>
<p class="sw-empty__sub">
Try a different lifecycle tab or clear the search to see everything.
</p>
</div>
`;
}
function renderFilePreview(
proposal: SkillWorkshopProposal,
file: SkillWorkshopFile,
onClose: () => void,
) {
const extension = file.path.split(".").pop()?.toUpperCase() ?? "FILE";
return html`
<div class="sw-drawer-backdrop" @click=${onClose}></div>
<aside class="sw-drawer" role="dialog" aria-label="File preview">
<header class="sw-drawer__head">
<div class="sw-drawer__icon">📄</div>
<div class="sw-drawer__meta">
<div class="sw-drawer__filename">${file.path}</div>
<div class="sw-drawer__sub">
<span>${file.size}</span><span class="sw-drawer__sep">·</span> <span>${extension}</span
><span class="sw-drawer__sep">·</span>
<span>read-only</span>
</div>
</div>
<button class="sw-drawer__close" title="Close (Esc)" @click=${onClose}>×</button>
</header>
<div class="sw-drawer__body">
<pre class="sw-drawer__pre">${file.contents}</pre>
</div>
<footer class="sw-drawer__foot">
<button class="sw-btn">✎ Edit this file</button>
<button class="sw-btn sw-btn--ghost">Copy contents</button>
<span class="sw-action-bar__spacer"></span>
<span class="sw-drawer__esc"> <code>Esc</code> close · proposal: ${proposal.slug} </span>
</footer>
</aside>
`;
}
function renderProposalBody(body: string) {
const lines = body.split("\n");
const out: unknown[] = [];
let para: string[] = [];
let list: string[] = [];
let inCode = false;
let codeBuf: string[] = [];
const flushPara = () => {
if (para.length) {
out.push(html`<p>${renderInline(para.join(" "))}</p>`);
para = [];
}
};
const flushList = () => {
if (list.length) {
const items = list;
out.push(html`
<ol>
${items.map((line) => html`<li>${renderInline(line)}</li>`)}
</ol>
`);
list = [];
}
};
for (const raw of lines) {
const line = raw.trimEnd();
if (line.startsWith("```")) {
flushPara();
flushList();
if (inCode) {
out.push(html`<pre>${codeBuf.join("\n")}</pre>`);
codeBuf = [];
inCode = false;
} else {
inCode = true;
}
continue;
}
if (inCode) {
codeBuf.push(raw);
continue;
}
if (line === "") {
flushPara();
flushList();
continue;
}
if (line.startsWith("## ")) {
flushPara();
flushList();
out.push(html`<h3>${line.slice(3)}</h3>`);
continue;
}
if (line.startsWith("# ")) {
flushPara();
flushList();
out.push(html`<h3>${line.slice(2)}</h3>`);
continue;
}
const olMatch = /^\d+\.\s+(.+)/.exec(line);
if (olMatch) {
flushPara();
list.push(olMatch[1]);
continue;
}
para.push(line);
}
flushPara();
flushList();
if (inCode && codeBuf.length) {
out.push(html`<pre>${codeBuf.join("\n")}</pre>`);
}
return out;
}
// Inline render: handles `code` and **bold** in text segments.
function renderInline(text: string): unknown {
const parts: unknown[] = [];
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(text))) {
if (match.index > last) {
parts.push(text.slice(last, match.index));
}
const token = match[0];
if (token.startsWith("`")) {
parts.push(html`<code>${token.slice(1, -1)}</code>`);
} else {
parts.push(html`<strong>${token.slice(2, -2)}</strong>`);
}
last = match.index + token.length;
}
if (last < text.length) {
parts.push(text.slice(last));
}
return parts;
}
function filterProposals(
proposals: SkillWorkshopProposal[],
statusFilter: SkillWorkshopStatusFilter,
query: string,
): SkillWorkshopProposal[] {
const q = query.trim().toLowerCase();
return proposals.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) {
return false;
}
if (q) {
const hay = `${p.name} ${p.oneLine} ${p.slug}`.toLowerCase();
if (!hay.includes(q)) {
return false;
}
}
return true;
});
}
function groupByRecency(
proposals: SkillWorkshopProposal[],
): Array<{ label: string; items: SkillWorkshopProposal[] }> {
const buckets = new Map<SkillWorkshopProposal["recencyGroup"], SkillWorkshopProposal[]>();
for (const proposal of proposals) {
const list = buckets.get(proposal.recencyGroup) ?? [];
list.push(proposal);
buckets.set(proposal.recencyGroup, list);
}
const order: Array<SkillWorkshopProposal["recencyGroup"]> = ["today", "yesterday", "earlier"];
return order
.filter((key) => buckets.has(key))
.map((key) => ({ label: GROUP_LABEL[key], items: buckets.get(key) ?? [] }));
}
function queueEmptyText(props: SkillWorkshopProps): string {
if (props.loading) {
return "Loading proposals…";
}
if (props.statusFilter !== "all") {
return `No ${STATUS_LABEL[props.statusFilter].toLowerCase()} proposals.`;
}
return "No proposals match the current filter.";
}
function formatRelative(ms: number): string {
const diff = Math.max(0, Date.now() - ms);
const sec = Math.floor(diff / 1000);
if (sec < 60) {
return `${sec}s ago`;
}
const min = Math.floor(sec / 60);
if (min < 60) {
return `${min} minutes ago`;
}
const hr = Math.floor(min / 60);
if (hr < 24) {
return `${hr}h ago`;
}
const day = Math.floor(hr / 24);
if (day < 7) {
return `${day}d ago`;
}
return new Date(ms).toLocaleDateString();
}
let cachedDemoProposals: SkillWorkshopProposal[] | null = null;
export function getDemoSkillWorkshopProposals(): SkillWorkshopProposal[] {
if (!cachedDemoProposals) {
cachedDemoProposals = buildDemoSkillWorkshopProposals();
}
return cachedDemoProposals;
}
// Demo data so the page actually renders the design before the gateway wires up.
// Drop this once `skills.proposals.list` is wired.
export function buildDemoSkillWorkshopProposals(): SkillWorkshopProposal[] {
const now = Date.now();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const morningBody = `## When to use
First thing in the morning when the user wants to start the day with a cleared inbox and a concrete plan. Trigger phrases: \`morning catch up\`, \`clear my inbox\`, \`what should I do today\`.
## Steps
1. **Triage.** Read unread messages across mail, Slack, and Discord. Skip threads where the user is just CC'd unless flagged.
2. **Archive.** Sort newsletters, receipts, and automated alerts into their normal folders.
3. **Surface.** List anything that needs the user's reply today, with a one-line "why" each.
4. **Draft.** For the top three replies, write a short draft in the user's voice. Do not send.
5. **Plan.** Propose a 3-item focus list for the day. Match against calendar gaps.
## Output
\`\`\`
## Needs reply
- Jen (vendor renewal) — wants pricing by Wed
- Marcus (interview confirm) — needs slot
## Today's three
1. Finish Q3 deck draft
2. Approve onboarding copy
3. 30-min focus block on the API doc
\`\`\``;
return [
{
key: "morning-catchup",
slug: "morning-catchup",
name: "Morning catch-up",
oneLine:
"Summarise overnight emails, Slack DMs, and PR reviews into one digest you can read in two minutes.",
body: morningBody,
status: "pending",
version: 1,
createdAt: now - 2 * minute,
recencyGroup: "today",
ageLabel: "2m",
isNew: true,
supportFiles: [
{
path: "templates/digest.md",
size: "2.1 KB",
contents: `# Morning digest template
Used by morning-catchup when posting the daily summary back to the user. Sections render in this order. Skip any section that has no items.
## Needs reply
Bulleted list. One line each. Format: - {sender} ({why}) — {ask}
Example:
- Jen (vendor renewal) — wants pricing by Wed
- Marcus (interview confirm) — needs slot
## Today's three
A numbered list of three focus items, in priority order. Match against calendar gaps when possible.
1. {top priority — what + why now}
2. {second priority}
3. {third priority — short focus block ok}
## Archived
Optional. One line summary count: Archived 14 items (newsletters, receipts, automated alerts).
## Footer
Always end with the timestamp and how long the catch-up took:
_Catch-up complete · {duration}s · {timestamp}_
`,
},
{
path: "filters/auto-senders.txt",
size: "418 B",
contents: `noreply@*
notifications@github.com
no-reply@*
calendar-notifications@*
reply+*@reply.github.com
account-update@*
billing@*
*receipts@*
mailer-daemon@*
postmaster@*
`,
},
{
path: "prompts/group-by-importance.md",
size: "1.4 KB",
contents: `# Group by importance
Given a set of unread messages, return three buckets:
1. **Needs reply today** — direct asks, time-sensitive threads, anything the user is the
sole owner of.
2. **FYI** — useful context, but not actionable today. Mention briefly without surfacing.
3. **Archive** — newsletters, automated alerts, marketing.
For each item in bucket 1, include:
- sender
- one-line "why now"
- suggested next action
`,
},
],
},
{
key: "birthday-reminders",
slug: "birthday-reminders",
name: "Birthday reminders",
oneLine: "Surface contacts with birthdays in the next 7 days from Google Contacts.",
body: `## When to use
Daily at the start of the day, surface upcoming birthdays so the user can send a quick note.
## Steps
1. Read Google Contacts birthdays for the next 7 days.
2. Group by day and skip duplicates.
3. For each contact, suggest a one-line greeting in the user's voice.
`,
status: "pending",
version: 1,
createdAt: now - 14 * minute,
recencyGroup: "today",
ageLabel: "14m",
isNew: true,
supportFiles: [],
},
{
key: "invoice-followup",
slug: "invoice-followup",
name: "Invoice follow-up",
oneLine: "Draft a polite nudge for invoices unpaid > 14 days.",
body: `## When to use
When AR shows invoices past their net-14 due date and no reply has been received.
## Steps
1. Pull invoices older than 14 days from Stripe / QuickBooks.
2. Cross-reference any payment received since the last sync.
3. Draft a polite reminder per overdue invoice. Do not send.
`,
status: "pending",
version: 2,
createdAt: now - 80 * minute,
updatedAt: now - 60 * minute,
recencyGroup: "today",
ageLabel: "1h",
isNew: true,
supportFiles: [],
},
{
key: "trip-planning",
slug: "trip-planning",
name: "Trip planning",
oneLine: "Take a city + dates, return flights, hotels, and a day-by-day plan.",
body: `## When to use
When the user names a destination and travel window.
## Steps
1. Search flights for the given window.
2. Suggest two hotel tiers near the main activity area.
3. Draft a day-by-day plan with one anchor activity per day.
`,
status: "pending",
version: 1,
createdAt: now - 2 * hour,
recencyGroup: "today",
ageLabel: "2h",
isNew: true,
supportFiles: [],
},
{
key: "screenshot-cleanup",
slug: "screenshot-cleanup",
name: "Screenshot cleanup",
oneLine: "Move screenshots older than 30 days from Desktop to ~/Archive.",
body: `## When to use
Weekly or on demand when the Desktop is cluttered with screenshots.
## Steps
1. List screenshots on Desktop older than 30 days.
2. Move them into ~/Archive/screenshots/{yyyy-mm}/.
3. Report counts moved and any conflicts skipped.
`,
status: "applied",
version: 1,
createdAt: now - 1 * day,
recencyGroup: "yesterday",
ageLabel: "1d",
isNew: false,
supportFiles: [],
},
{
key: "standup-notes",
slug: "standup-notes",
name: "Standup notes",
oneLine: "Generate daily standup from yesterday's git commits + calendar.",
body: `## When to use
Every weekday morning before standup, the user wants a one-screen summary.
## Steps
1. Read yesterday's git commits across pinned repos.
2. Read yesterday's accepted calendar events.
3. Combine into three bullets: yesterday / today / blockers.
`,
status: "pending",
version: 1,
createdAt: now - 1 * day,
recencyGroup: "yesterday",
ageLabel: "1d",
isNew: false,
supportFiles: [],
},
{
key: "repo-cleanup",
slug: "repo-cleanup",
name: "Repo cleanup",
oneLine: "Identify branches merged > 30 days ago, suggest deletion.",
body: `## When to use
Monthly hygiene. The user wants a short list of stale branches to delete.
## Steps
1. List branches across pinned repos.
2. Filter to those merged > 30 days ago.
3. Suggest deletion grouped by repo. Do not delete.
`,
status: "pending",
version: 1,
createdAt: now - 4 * day,
recencyGroup: "earlier",
ageLabel: "4d",
isNew: false,
supportFiles: [],
},
];
}
export function countProposals(
proposals: SkillWorkshopProposal[],
): Record<SkillWorkshopStatusFilter, number> {
const counts: Record<SkillWorkshopStatusFilter, number> = {
all: proposals.length,
pending: 0,
applied: 0,
rejected: 0,
quarantined: 0,
stale: 0,
};
for (const p of proposals) {
counts[p.status] += 1;
}
return counts;
}