stagent 0.5.0 → 0.6.0
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.
- package/README.md +8 -8
- package/dist/cli.js +146 -2
- package/docs/.coverage-gaps.json +21 -0
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +36 -14
- package/docs/features/chat.md +33 -56
- package/docs/features/cost-usage.md +14 -10
- package/docs/features/dashboard-kanban.md +30 -13
- package/docs/features/delivery-channels.md +198 -0
- package/docs/features/design-system.md +10 -10
- package/docs/features/documents.md +8 -8
- package/docs/features/home-workspace.md +20 -15
- package/docs/features/inbox-notifications.md +22 -10
- package/docs/features/keyboard-navigation.md +11 -11
- package/docs/features/monitoring.md +1 -1
- package/docs/features/playbook.md +30 -32
- package/docs/features/profiles.md +33 -11
- package/docs/features/projects.md +2 -2
- package/docs/features/provider-runtimes.md +58 -14
- package/docs/features/schedules.md +70 -40
- package/docs/features/settings.md +74 -46
- package/docs/features/shared-components.md +7 -15
- package/docs/features/tool-permissions.md +9 -9
- package/docs/features/workflows.md +32 -21
- package/docs/getting-started.md +33 -9
- package/docs/index.md +25 -16
- package/docs/journeys/developer.md +124 -207
- package/docs/journeys/personal-use.md +70 -79
- package/docs/journeys/power-user.md +107 -151
- package/docs/journeys/work-use.md +81 -113
- package/docs/manifest.json +77 -45
- package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
- package/docs/use-cases/agency-operator.md +84 -0
- package/docs/use-cases/solo-founder.md +75 -0
- package/docs/why-stagent.md +59 -0
- package/package.json +10 -3
- package/src/app/api/channels/[id]/route.ts +103 -0
- package/src/app/api/channels/[id]/test/route.ts +52 -0
- package/src/app/api/channels/inbound/slack/route.ts +109 -0
- package/src/app/api/channels/inbound/telegram/poll/route.ts +128 -0
- package/src/app/api/channels/inbound/telegram/route.ts +76 -0
- package/src/app/api/channels/route.ts +71 -0
- package/src/app/api/chat/conversations/route.ts +15 -0
- package/src/app/api/chat/entities/search/route.ts +46 -31
- package/src/app/api/environment/profiles/suggest/route.ts +19 -3
- package/src/app/api/environment/scan/route.ts +8 -1
- package/src/app/api/handoffs/[id]/route.ts +76 -0
- package/src/app/api/handoffs/route.ts +89 -0
- package/src/app/api/memory/route.ts +181 -0
- package/src/app/api/profiles/[id]/route.ts +16 -1
- package/src/app/api/profiles/[id]/test/route.ts +4 -0
- package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
- package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
- package/src/app/api/profiles/assist/route.ts +35 -0
- package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
- package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
- package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
- package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
- package/src/app/api/profiles/import-repo/route.ts +29 -0
- package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
- package/src/app/api/profiles/route.ts +73 -22
- package/src/app/api/runtimes/ollama/route.ts +86 -0
- package/src/app/api/runtimes/suggest/route.ts +29 -0
- package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
- package/src/app/api/schedules/[id]/route.ts +41 -3
- package/src/app/api/schedules/parse/route.ts +66 -0
- package/src/app/api/schedules/route.ts +71 -12
- package/src/app/api/settings/author-default/route.ts +7 -0
- package/src/app/api/settings/learning/route.ts +41 -0
- package/src/app/api/settings/ollama/route.ts +34 -0
- package/src/app/api/settings/providers/route.ts +57 -0
- package/src/app/api/settings/routing/route.ts +24 -0
- package/src/app/api/settings/web-search/route.ts +28 -0
- package/src/app/api/tasks/[id]/execute/route.ts +13 -1
- package/src/app/documents/page.tsx +3 -0
- package/src/app/environment/page.tsx +8 -1
- package/src/app/settings/page.tsx +10 -4
- package/src/app/workflows/[id]/edit/page.tsx +2 -0
- package/src/app/workflows/new/page.tsx +2 -0
- package/src/components/chat/chat-command-popover.tsx +22 -19
- package/src/components/chat/chat-input.tsx +5 -0
- package/src/components/chat/chat-model-selector.tsx +42 -1
- package/src/components/chat/chat-shell.tsx +2 -0
- package/src/components/dashboard/welcome-landing.tsx +9 -9
- package/src/components/environment/artifact-card.tsx +27 -1
- package/src/components/environment/environment-dashboard.tsx +50 -2
- package/src/components/environment/environment-summary-card.tsx +5 -2
- package/src/components/environment/suggested-profiles.tsx +117 -52
- package/src/components/handoffs/handoff-approval-card.tsx +159 -0
- package/src/components/memory/memory-browser.tsx +315 -0
- package/src/components/profiles/learned-context-panel.tsx +4 -4
- package/src/components/profiles/profile-assist-panel.tsx +512 -0
- package/src/components/profiles/profile-browser.tsx +109 -8
- package/src/components/profiles/profile-card.tsx +29 -1
- package/src/components/profiles/profile-detail-view.tsx +200 -28
- package/src/components/profiles/profile-form-view.tsx +220 -82
- package/src/components/profiles/repo-import-wizard.tsx +648 -0
- package/src/components/profiles/smoke-test-editor.tsx +106 -0
- package/src/components/schedules/schedule-create-sheet.tsx +9 -1
- package/src/components/schedules/schedule-form.tsx +348 -9
- package/src/components/schedules/schedule-list.tsx +15 -2
- package/src/components/settings/auth-method-selector.tsx +7 -1
- package/src/components/settings/budget-guardrails-section.tsx +111 -48
- package/src/components/settings/channels-section.tsx +526 -0
- package/src/components/settings/chat-settings-section.tsx +27 -1
- package/src/components/settings/data-management-section.tsx +8 -6
- package/src/components/settings/learning-context-section.tsx +124 -0
- package/src/components/settings/ollama-section.tsx +270 -0
- package/src/components/settings/providers-runtimes-section.tsx +499 -0
- package/src/components/settings/web-search-section.tsx +101 -0
- package/src/components/shared/tag-input.tsx +156 -0
- package/src/components/tasks/kanban-board.tsx +32 -0
- package/src/components/tasks/kanban-column.tsx +4 -2
- package/src/components/tasks/task-card.tsx +1 -0
- package/src/components/tasks/task-chip-bar.tsx +6 -1
- package/src/components/tasks/task-create-panel.tsx +55 -5
- package/src/components/workflows/workflow-form-view.tsx +38 -3
- package/src/hooks/use-chat-autocomplete.ts +24 -26
- package/src/hooks/use-project-skills.ts +66 -0
- package/src/hooks/use-tag-suggestions.ts +31 -0
- package/src/instrumentation.ts +4 -1
- package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
- package/src/lib/agents/agentic-loop.ts +235 -0
- package/src/lib/agents/browser-mcp.ts +59 -4
- package/src/lib/agents/claude-agent.ts +26 -199
- package/src/lib/agents/handoff/bus.ts +164 -0
- package/src/lib/agents/handoff/governance.ts +47 -0
- package/src/lib/agents/handoff/types.ts +16 -0
- package/src/lib/agents/learned-context.ts +27 -7
- package/src/lib/agents/memory/decay.ts +61 -0
- package/src/lib/agents/memory/extractor.ts +181 -0
- package/src/lib/agents/memory/retrieval.ts +96 -0
- package/src/lib/agents/memory/types.ts +6 -0
- package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
- package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/project-profiles.ts +193 -0
- package/src/lib/agents/profiles/registry.ts +130 -6
- package/src/lib/agents/profiles/types.ts +28 -0
- package/src/lib/agents/router.ts +174 -2
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
- package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
- package/src/lib/agents/runtime/catalog.ts +57 -2
- package/src/lib/agents/runtime/claude.ts +205 -1
- package/src/lib/agents/runtime/index.ts +22 -0
- package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
- package/src/lib/agents/runtime/openai-direct.ts +514 -0
- package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
- package/src/lib/agents/runtime/types.ts +2 -0
- package/src/lib/agents/tool-permissions.ts +203 -0
- package/src/lib/channels/gateway.ts +321 -0
- package/src/lib/channels/poller.ts +268 -0
- package/src/lib/channels/registry.ts +90 -0
- package/src/lib/channels/slack-adapter.ts +188 -0
- package/src/lib/channels/telegram-adapter.ts +218 -0
- package/src/lib/channels/types.ts +43 -0
- package/src/lib/channels/webhook-adapter.ts +74 -0
- package/src/lib/chat/context-builder.ts +22 -2
- package/src/lib/chat/engine.ts +95 -13
- package/src/lib/chat/ollama-engine.ts +198 -0
- package/src/lib/chat/stagent-tools.ts +106 -20
- package/src/lib/chat/tool-catalog.ts +24 -0
- package/src/lib/chat/tool-registry.ts +90 -0
- package/src/lib/chat/tools/chat-history-tools.ts +4 -4
- package/src/lib/chat/tools/document-tools.ts +7 -7
- package/src/lib/chat/tools/handoff-tools.ts +70 -0
- package/src/lib/chat/tools/notification-tools.ts +4 -4
- package/src/lib/chat/tools/profile-tools.ts +3 -3
- package/src/lib/chat/tools/project-tools.ts +3 -3
- package/src/lib/chat/tools/schedule-tools.ts +29 -13
- package/src/lib/chat/tools/settings-tools.ts +2 -2
- package/src/lib/chat/tools/task-tools.ts +66 -11
- package/src/lib/chat/tools/usage-tools.ts +2 -2
- package/src/lib/chat/tools/workflow-tools.ts +8 -8
- package/src/lib/chat/types.ts +11 -5
- package/src/lib/constants/known-tools.ts +19 -0
- package/src/lib/constants/prose-styles.ts +1 -1
- package/src/lib/constants/settings.ts +7 -0
- package/src/lib/data/channel-bindings.ts +85 -0
- package/src/lib/data/clear.ts +22 -0
- package/src/lib/data/profile-test-results.ts +48 -0
- package/src/lib/data/seed-data/conversations.ts +196 -0
- package/src/lib/data/seed-data/learned-context.ts +99 -0
- package/src/lib/data/seed-data/notifications.ts +54 -1
- package/src/lib/data/seed-data/profile-test-results.ts +96 -0
- package/src/lib/data/seed-data/repo-imports.ts +51 -0
- package/src/lib/data/seed-data/views.ts +60 -0
- package/src/lib/data/seed.ts +51 -0
- package/src/lib/db/bootstrap.ts +162 -0
- package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
- package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
- package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
- package/src/lib/db/schema.ts +187 -1
- package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
- package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
- package/src/lib/environment/auto-scan.ts +48 -0
- package/src/lib/environment/data.ts +25 -0
- package/src/lib/environment/profile-generator.ts +40 -10
- package/src/lib/environment/profile-linker.ts +143 -0
- package/src/lib/environment/profile-rules.ts +96 -0
- package/src/lib/import/dedup.ts +149 -0
- package/src/lib/import/format-adapter.ts +631 -0
- package/src/lib/import/github-api.ts +219 -0
- package/src/lib/import/repo-scanner.ts +251 -0
- package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
- package/src/lib/schedules/active-hours.ts +120 -0
- package/src/lib/schedules/heartbeat-parser.ts +224 -0
- package/src/lib/schedules/heartbeat-prompt.ts +153 -0
- package/src/lib/schedules/nlp-parser.ts +357 -0
- package/src/lib/schedules/scheduler.ts +218 -3
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
- package/src/lib/settings/helpers.ts +6 -0
- package/src/lib/settings/routing.ts +24 -0
- package/src/lib/settings/runtime-setup.ts +28 -1
- package/src/lib/usage/ledger.ts +2 -1
- package/src/lib/validators/__tests__/settings.test.ts +9 -0
- package/src/lib/validators/profile.ts +39 -0
- package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
- package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
- package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
- package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
- package/src/lib/workflows/blueprints/builtins/lead-research-pipeline.yaml +82 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@/lib/db", () => {
|
|
4
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
5
|
+
return {
|
|
6
|
+
db: {
|
|
7
|
+
select: vi.fn(() => ({
|
|
8
|
+
from: vi.fn(() => ({
|
|
9
|
+
where: vi.fn(() => ({
|
|
10
|
+
all: vi.fn(() => rows),
|
|
11
|
+
})),
|
|
12
|
+
})),
|
|
13
|
+
})),
|
|
14
|
+
update: vi.fn(() => ({
|
|
15
|
+
set: vi.fn(() => ({
|
|
16
|
+
where: vi.fn(() => ({
|
|
17
|
+
run: vi.fn(),
|
|
18
|
+
})),
|
|
19
|
+
})),
|
|
20
|
+
})),
|
|
21
|
+
__rows: rows,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
vi.mock("@/lib/db/schema", () => ({
|
|
27
|
+
environmentArtifacts: {
|
|
28
|
+
scanId: "scan_id",
|
|
29
|
+
category: "category",
|
|
30
|
+
id: "id",
|
|
31
|
+
linkedProfileId: "linked_profile_id",
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("@/lib/agents/profiles/registry", () => ({
|
|
36
|
+
listAllProfiles: vi.fn(() => []),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { linkArtifactsToProfiles } from "../profile-linker";
|
|
40
|
+
import { listAllProfiles } from "@/lib/agents/profiles/registry";
|
|
41
|
+
import { db } from "@/lib/db";
|
|
42
|
+
|
|
43
|
+
const mockListAllProfiles = listAllProfiles as ReturnType<typeof vi.fn>;
|
|
44
|
+
|
|
45
|
+
function makeArtifact(id: string, name: string, absPath: string) {
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
scanId: "scan-1",
|
|
49
|
+
tool: "claude-code",
|
|
50
|
+
category: "skill",
|
|
51
|
+
scope: "user",
|
|
52
|
+
name,
|
|
53
|
+
relPath: `skills/${name}/SKILL.md`,
|
|
54
|
+
absPath,
|
|
55
|
+
contentHash: "abc123",
|
|
56
|
+
preview: null,
|
|
57
|
+
metadata: null,
|
|
58
|
+
sizeBytes: 100,
|
|
59
|
+
modifiedAt: Date.now(),
|
|
60
|
+
linkedProfileId: null,
|
|
61
|
+
createdAt: new Date(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("linkArtifactsToProfiles", () => {
|
|
70
|
+
it("returns zeros when no skill artifacts exist", () => {
|
|
71
|
+
// Mock select to return empty array for skill artifacts
|
|
72
|
+
const mockAll = vi.fn(() => []);
|
|
73
|
+
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
74
|
+
from: vi.fn(() => ({
|
|
75
|
+
where: vi.fn(() => ({
|
|
76
|
+
all: mockAll,
|
|
77
|
+
})),
|
|
78
|
+
})),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const result = linkArtifactsToProfiles("scan-1");
|
|
82
|
+
expect(result.linked).toBe(0);
|
|
83
|
+
expect(result.unlinked).toBe(0);
|
|
84
|
+
expect(result.unlinkedArtifactIds).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("links artifacts to matching profiles by directory basename", () => {
|
|
88
|
+
const artifacts = [
|
|
89
|
+
makeArtifact("a1", "code-reviewer", "/home/.claude/skills/code-reviewer/SKILL.md"),
|
|
90
|
+
makeArtifact("a2", "researcher", "/home/.claude/skills/researcher/SKILL.md"),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const mockAll = vi.fn(() => artifacts);
|
|
94
|
+
const mockRun = vi.fn();
|
|
95
|
+
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
96
|
+
from: vi.fn(() => ({
|
|
97
|
+
where: vi.fn(() => ({
|
|
98
|
+
all: mockAll,
|
|
99
|
+
})),
|
|
100
|
+
})),
|
|
101
|
+
});
|
|
102
|
+
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
103
|
+
set: vi.fn(() => ({
|
|
104
|
+
where: vi.fn(() => ({
|
|
105
|
+
run: mockRun,
|
|
106
|
+
})),
|
|
107
|
+
})),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
mockListAllProfiles.mockReturnValue([
|
|
111
|
+
{ id: "code-reviewer", name: "Code Reviewer" },
|
|
112
|
+
{ id: "researcher", name: "Researcher" },
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const result = linkArtifactsToProfiles("scan-1");
|
|
116
|
+
expect(result.linked).toBe(2);
|
|
117
|
+
expect(result.unlinked).toBe(0);
|
|
118
|
+
expect(db.update).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("marks unmatched artifacts as unlinked", () => {
|
|
122
|
+
const artifacts = [
|
|
123
|
+
makeArtifact("a1", "code-reviewer", "/home/.claude/skills/code-reviewer/SKILL.md"),
|
|
124
|
+
makeArtifact("a2", "unknown-skill", "/home/.claude/skills/unknown-skill/SKILL.md"),
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const mockAll = vi.fn(() => artifacts);
|
|
128
|
+
const mockRun = vi.fn();
|
|
129
|
+
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
130
|
+
from: vi.fn(() => ({
|
|
131
|
+
where: vi.fn(() => ({
|
|
132
|
+
all: mockAll,
|
|
133
|
+
})),
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
137
|
+
set: vi.fn(() => ({
|
|
138
|
+
where: vi.fn(() => ({
|
|
139
|
+
run: mockRun,
|
|
140
|
+
})),
|
|
141
|
+
})),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
mockListAllProfiles.mockReturnValue([
|
|
145
|
+
{ id: "code-reviewer", name: "Code Reviewer" },
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
const result = linkArtifactsToProfiles("scan-1");
|
|
149
|
+
expect(result.linked).toBe(1);
|
|
150
|
+
expect(result.unlinked).toBe(1);
|
|
151
|
+
expect(result.unlinkedArtifactIds).toContain("a2");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("handles directory-style absPath (no file extension)", () => {
|
|
155
|
+
// The scanner sometimes stores the directory, not the file
|
|
156
|
+
const artifacts = [
|
|
157
|
+
makeArtifact("a1", "code-reviewer", "/home/.claude/skills/code-reviewer"),
|
|
158
|
+
makeArtifact("a2", "general", "/home/.claude/skills/general"),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const mockAll = vi.fn(() => artifacts);
|
|
162
|
+
const mockRun = vi.fn();
|
|
163
|
+
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
164
|
+
from: vi.fn(() => ({
|
|
165
|
+
where: vi.fn(() => ({
|
|
166
|
+
all: mockAll,
|
|
167
|
+
})),
|
|
168
|
+
})),
|
|
169
|
+
});
|
|
170
|
+
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
171
|
+
set: vi.fn(() => ({
|
|
172
|
+
where: vi.fn(() => ({
|
|
173
|
+
run: mockRun,
|
|
174
|
+
})),
|
|
175
|
+
})),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
mockListAllProfiles.mockReturnValue([
|
|
179
|
+
{ id: "code-reviewer", name: "Code Reviewer" },
|
|
180
|
+
{ id: "general", name: "General" },
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const result = linkArtifactsToProfiles("scan-1");
|
|
184
|
+
expect(result.linked).toBe(2);
|
|
185
|
+
expect(result.unlinked).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-scan module.
|
|
3
|
+
* Triggers environment scans when the last scan is stale (>5 min) or missing.
|
|
4
|
+
* All functions are synchronous — scanEnvironment() and the DB layer are sync.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getLatestScan, createScan } from "./data";
|
|
8
|
+
import { scanEnvironment } from "./scanner";
|
|
9
|
+
import type { ScanResult } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Staleness threshold in milliseconds (5 minutes). */
|
|
12
|
+
const STALENESS_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Returns true if no scan exists or the latest scan is older than STALENESS_MS. */
|
|
15
|
+
export function shouldRescan(projectId?: string): boolean {
|
|
16
|
+
const latest = getLatestScan(projectId);
|
|
17
|
+
if (!latest) return true;
|
|
18
|
+
|
|
19
|
+
const scannedAt =
|
|
20
|
+
latest.scannedAt instanceof Date
|
|
21
|
+
? latest.scannedAt.getTime()
|
|
22
|
+
: new Date(latest.scannedAt).getTime();
|
|
23
|
+
|
|
24
|
+
return Date.now() - scannedAt > STALENESS_MS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensures a fresh environment scan exists for the given project directory.
|
|
29
|
+
* Runs a new scan if the latest one is stale or missing.
|
|
30
|
+
* Returns the scan result if a new scan was performed, or null if already fresh.
|
|
31
|
+
*
|
|
32
|
+
* Errors are caught and logged — auto-scan must never block the caller.
|
|
33
|
+
*/
|
|
34
|
+
export function ensureFreshScan(
|
|
35
|
+
projectDir: string,
|
|
36
|
+
projectId?: string
|
|
37
|
+
): ScanResult | null {
|
|
38
|
+
try {
|
|
39
|
+
if (!shouldRescan(projectId)) return null;
|
|
40
|
+
|
|
41
|
+
const result = scanEnvironment({ projectDir });
|
|
42
|
+
createScan(result, projectDir, projectId);
|
|
43
|
+
return result;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn("Auto-scan failed (non-blocking):", error);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@/lib/db/schema";
|
|
17
17
|
import { eq, desc, and, like, sql } from "drizzle-orm";
|
|
18
18
|
import type { ScanResult, ArtifactCategory, ToolPersona, ArtifactScope } from "./types";
|
|
19
|
+
import { linkArtifactsToProfiles } from "./profile-linker";
|
|
19
20
|
|
|
20
21
|
/** Persist a scan result (scan + all artifacts) in a single transaction. */
|
|
21
22
|
export function createScan(
|
|
@@ -70,6 +71,13 @@ export function createScan(
|
|
|
70
71
|
}
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
// Link skill artifacts to their corresponding profiles
|
|
75
|
+
try {
|
|
76
|
+
linkArtifactsToProfiles(scanId);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn("[environment] Profile linking failed (non-blocking):", err);
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
return db
|
|
74
82
|
.select()
|
|
75
83
|
.from(environmentScans)
|
|
@@ -77,6 +85,23 @@ export function createScan(
|
|
|
77
85
|
.get()!;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Invalidate the latest scan by marking it as stale.
|
|
90
|
+
* Called after profile mutations to ensure the next ensureFreshScan() re-scans.
|
|
91
|
+
*/
|
|
92
|
+
export function invalidateLatestScan(projectId?: string): void {
|
|
93
|
+
const latest = getLatestScan(projectId);
|
|
94
|
+
if (!latest) return;
|
|
95
|
+
|
|
96
|
+
// Set scannedAt far enough in the past that shouldRescan() returns true.
|
|
97
|
+
// The auto-scan staleness window is 5 minutes, so subtracting 10 minutes is safe.
|
|
98
|
+
const staleTime = new Date(Date.now() - 10 * 60 * 1000);
|
|
99
|
+
db.update(environmentScans)
|
|
100
|
+
.set({ scannedAt: staleTime })
|
|
101
|
+
.where(eq(environmentScans.id, latest.id))
|
|
102
|
+
.run();
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
/** Get the most recent completed scan. */
|
|
81
106
|
export function getLatestScan(projectId?: string): EnvironmentScanRow | undefined {
|
|
82
107
|
const conditions = [eq(environmentScans.scanStatus, "completed")];
|
|
@@ -3,31 +3,57 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { getArtifacts } from "./data";
|
|
6
|
-
import { evaluateRules, type ProfileSuggestion } from "./profile-rules";
|
|
6
|
+
import { evaluateRules, generateTier2Suggestions, type ProfileSuggestion } from "./profile-rules";
|
|
7
7
|
import { listProfiles, createProfile } from "@/lib/agents/profiles/registry";
|
|
8
8
|
import type { ProfileConfig } from "@/lib/validators/profile";
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const MIN_CURATED_CONFIDENCE = 0.6;
|
|
11
|
+
|
|
12
|
+
export interface TieredSuggestions {
|
|
13
|
+
curated: ProfileSuggestion[];
|
|
14
|
+
discovered: ProfileSuggestion[];
|
|
15
|
+
}
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Suggest profiles based on artifacts from a scan.
|
|
19
|
+
* Returns both curated (Tier 1) and discovered (Tier 2) suggestions.
|
|
14
20
|
* Filters out suggestions that match existing profiles.
|
|
15
21
|
*/
|
|
16
|
-
export function
|
|
22
|
+
export function suggestProfilesTiered(scanId: string): TieredSuggestions {
|
|
17
23
|
const artifacts = getArtifacts({ scanId });
|
|
18
|
-
const suggestions = evaluateRules(artifacts);
|
|
19
|
-
|
|
20
|
-
// Filter by minimum confidence
|
|
21
|
-
const confident = suggestions.filter((s) => s.confidence >= MIN_CONFIDENCE);
|
|
22
24
|
|
|
23
|
-
//
|
|
25
|
+
// Tier 1: Curated rules
|
|
26
|
+
const curatedRaw = evaluateRules(artifacts);
|
|
24
27
|
const existing = listProfiles();
|
|
25
28
|
const existingIds = new Set(existing.map((p) => p.id));
|
|
26
29
|
const existingNames = new Set(existing.map((p) => p.name.toLowerCase()));
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
(s) =>
|
|
31
|
+
const curated = curatedRaw
|
|
32
|
+
.filter((s) => s.confidence >= MIN_CURATED_CONFIDENCE)
|
|
33
|
+
.filter(
|
|
34
|
+
(s) => !existingIds.has(s.ruleId) && !existingNames.has(s.name.toLowerCase())
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Tier 2: Unlinked skill artifacts (those without linkedProfileId)
|
|
38
|
+
const unlinkedSkills = artifacts.filter(
|
|
39
|
+
(a) => a.category === "skill" && !a.linkedProfileId
|
|
40
|
+
);
|
|
41
|
+
const discovered = generateTier2Suggestions(unlinkedSkills).filter(
|
|
42
|
+
(s) =>
|
|
43
|
+
!existingIds.has(s.ruleId) &&
|
|
44
|
+
!existingIds.has(`env-${s.ruleId}`) &&
|
|
45
|
+
!existingNames.has(s.name.toLowerCase())
|
|
30
46
|
);
|
|
47
|
+
|
|
48
|
+
return { curated, discovered };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Suggest profiles based on artifacts from a scan (legacy flat API).
|
|
53
|
+
* Returns only curated (Tier 1) suggestions for backward compatibility.
|
|
54
|
+
*/
|
|
55
|
+
export function suggestProfiles(scanId: string): ProfileSuggestion[] {
|
|
56
|
+
return suggestProfilesTiered(scanId).curated;
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
/**
|
|
@@ -98,4 +124,8 @@ export function createProfileFromSuggestion(
|
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
createProfile(config, skillMd);
|
|
127
|
+
|
|
128
|
+
// Note: the created profile will have author "stagent-env" which,
|
|
129
|
+
// combined with the env- prefix on the ID, identifies it as environment-originated.
|
|
130
|
+
// The profile registry can infer origin from the author field.
|
|
101
131
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile-Artifact Linker
|
|
3
|
+
*
|
|
4
|
+
* Reconciles environment skill artifacts with profile registry entries.
|
|
5
|
+
* Runs after each scan to populate linkedProfileId on skill artifacts,
|
|
6
|
+
* enabling the UI to show which skills are already profiles and which
|
|
7
|
+
* are candidates for promotion.
|
|
8
|
+
*
|
|
9
|
+
* Matching strategy: directory basename under ~/.claude/skills/ is the
|
|
10
|
+
* shared key between both systems.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { db } from "@/lib/db";
|
|
14
|
+
import { environmentArtifacts } from "@/lib/db/schema";
|
|
15
|
+
import { eq, and, isNull } from "drizzle-orm";
|
|
16
|
+
import { listAllProfiles } from "@/lib/agents/profiles/registry";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface LinkResult {
|
|
20
|
+
linked: number;
|
|
21
|
+
unlinked: number;
|
|
22
|
+
unlinkedArtifactIds: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Link skill artifacts from a scan to their corresponding profiles.
|
|
27
|
+
*
|
|
28
|
+
* For each skill artifact, extracts the directory basename from its absPath
|
|
29
|
+
* (e.g., ~/.claude/skills/code-reviewer/SKILL.md → "code-reviewer") and
|
|
30
|
+
* matches it against profile IDs in the registry.
|
|
31
|
+
*/
|
|
32
|
+
export function linkArtifactsToProfiles(
|
|
33
|
+
scanId: string,
|
|
34
|
+
projectDir?: string
|
|
35
|
+
): LinkResult {
|
|
36
|
+
// Get all skill artifacts from this scan
|
|
37
|
+
const skillArtifacts = db
|
|
38
|
+
.select()
|
|
39
|
+
.from(environmentArtifacts)
|
|
40
|
+
.where(
|
|
41
|
+
and(
|
|
42
|
+
eq(environmentArtifacts.scanId, scanId),
|
|
43
|
+
eq(environmentArtifacts.category, "skill")
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
.all();
|
|
47
|
+
|
|
48
|
+
if (skillArtifacts.length === 0) {
|
|
49
|
+
return { linked: 0, unlinked: 0, unlinkedArtifactIds: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build a set of known profile IDs
|
|
53
|
+
const profiles = listAllProfiles(projectDir);
|
|
54
|
+
const profileIds = new Set(profiles.map((p) => p.id));
|
|
55
|
+
|
|
56
|
+
let linked = 0;
|
|
57
|
+
const unlinkedArtifactIds: string[] = [];
|
|
58
|
+
|
|
59
|
+
// Match each skill artifact to a profile by directory basename
|
|
60
|
+
for (const artifact of skillArtifacts) {
|
|
61
|
+
const dirBasename = extractProfileId(artifact.absPath);
|
|
62
|
+
if (!dirBasename) {
|
|
63
|
+
unlinkedArtifactIds.push(artifact.id);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (profileIds.has(dirBasename)) {
|
|
68
|
+
// Link this artifact to the profile
|
|
69
|
+
db.update(environmentArtifacts)
|
|
70
|
+
.set({ linkedProfileId: dirBasename })
|
|
71
|
+
.where(eq(environmentArtifacts.id, artifact.id))
|
|
72
|
+
.run();
|
|
73
|
+
linked++;
|
|
74
|
+
} else {
|
|
75
|
+
unlinkedArtifactIds.push(artifact.id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
linked,
|
|
81
|
+
unlinked: unlinkedArtifactIds.length,
|
|
82
|
+
unlinkedArtifactIds,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract the profile ID from a skill artifact's absolute path.
|
|
88
|
+
*
|
|
89
|
+
* The scanner stores absPath as either:
|
|
90
|
+
* - The directory: ~/.claude/skills/code-reviewer
|
|
91
|
+
* - Or the file: ~/.claude/skills/code-reviewer/SKILL.md
|
|
92
|
+
*
|
|
93
|
+
* The profile ID is the skill directory basename ("code-reviewer").
|
|
94
|
+
*/
|
|
95
|
+
function extractProfileId(absPath: string): string | null {
|
|
96
|
+
// The absPath may point to the skill directory itself or a file within it.
|
|
97
|
+
// Use the basename of the path first — if it looks like a directory name
|
|
98
|
+
// (no extension), use it directly. Otherwise, use the parent directory.
|
|
99
|
+
const basename = path.basename(absPath);
|
|
100
|
+
|
|
101
|
+
// If basename has a file extension (e.g., "SKILL.md"), go up one level
|
|
102
|
+
if (basename.includes(".")) {
|
|
103
|
+
const parentBasename = path.basename(path.dirname(absPath));
|
|
104
|
+
if (parentBasename === "skills" || parentBasename === ".claude") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return parentBasename;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// basename is a directory name — skip if it's the skills root
|
|
111
|
+
if (basename === "skills" || basename === ".claude") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return basename;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get all unlinked skill artifact IDs for a scan.
|
|
120
|
+
* Useful for the suggestion engine to generate Tier 2 suggestions.
|
|
121
|
+
*/
|
|
122
|
+
export function getUnlinkedSkillArtifacts(
|
|
123
|
+
scanId: string
|
|
124
|
+
): Array<{ id: string; name: string; absPath: string; contentHash: string; preview: string | null; metadata: string | null }> {
|
|
125
|
+
return db
|
|
126
|
+
.select({
|
|
127
|
+
id: environmentArtifacts.id,
|
|
128
|
+
name: environmentArtifacts.name,
|
|
129
|
+
absPath: environmentArtifacts.absPath,
|
|
130
|
+
contentHash: environmentArtifacts.contentHash,
|
|
131
|
+
preview: environmentArtifacts.preview,
|
|
132
|
+
metadata: environmentArtifacts.metadata,
|
|
133
|
+
})
|
|
134
|
+
.from(environmentArtifacts)
|
|
135
|
+
.where(
|
|
136
|
+
and(
|
|
137
|
+
eq(environmentArtifacts.scanId, scanId),
|
|
138
|
+
eq(environmentArtifacts.category, "skill"),
|
|
139
|
+
isNull(environmentArtifacts.linkedProfileId)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
.all();
|
|
143
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Profile suggestion rules engine.
|
|
3
3
|
* Maps artifact clusters to agent profile suggestions.
|
|
4
|
+
*
|
|
5
|
+
* Two-tier system:
|
|
6
|
+
* - Tier 1 (Curated): 6 hardcoded rules with high confidence (0.65-1.0)
|
|
7
|
+
* - Tier 2 (Discovered): Any unlinked skill artifact with valid SKILL.md
|
|
8
|
+
* frontmatter becomes a generic suggestion at confidence 0.5
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import type { EnvironmentArtifactRow } from "@/lib/db/schema";
|
|
@@ -22,11 +27,14 @@ export interface ProfileRule {
|
|
|
22
27
|
tags: string[];
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
export type SuggestionTier = "curated" | "discovered";
|
|
31
|
+
|
|
25
32
|
export interface ProfileSuggestion {
|
|
26
33
|
ruleId: string;
|
|
27
34
|
name: string;
|
|
28
35
|
description: string;
|
|
29
36
|
confidence: number;
|
|
37
|
+
tier: SuggestionTier;
|
|
30
38
|
matchedArtifacts: Array<{ id: string; name: string; category: string }>;
|
|
31
39
|
suggestedTools: string[];
|
|
32
40
|
systemPrompt: string;
|
|
@@ -190,6 +198,7 @@ export function evaluateRules(
|
|
|
190
198
|
name: rule.name,
|
|
191
199
|
description: rule.description,
|
|
192
200
|
confidence,
|
|
201
|
+
tier: "curated",
|
|
193
202
|
matchedArtifacts: matched,
|
|
194
203
|
suggestedTools: rule.suggestedTools,
|
|
195
204
|
systemPrompt: rule.systemPromptTemplate,
|
|
@@ -199,3 +208,90 @@ export function evaluateRules(
|
|
|
199
208
|
|
|
200
209
|
return suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
201
210
|
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Tier 2: Auto-discovered suggestions from unlinked skill artifacts
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const TIER2_CONFIDENCE = 0.5;
|
|
217
|
+
|
|
218
|
+
/** Parse YAML frontmatter from SKILL.md preview content. */
|
|
219
|
+
function parseFrontmatter(preview: string | null): { name?: string; description?: string } {
|
|
220
|
+
if (!preview) return {};
|
|
221
|
+
const match = preview.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
222
|
+
if (!match) return {};
|
|
223
|
+
|
|
224
|
+
const result: { name?: string; description?: string } = {};
|
|
225
|
+
const lines = match[1].split("\n");
|
|
226
|
+
for (const line of lines) {
|
|
227
|
+
const nameMatch = line.match(/^name:\s*(.+)/);
|
|
228
|
+
if (nameMatch) result.name = nameMatch[1].trim();
|
|
229
|
+
const descMatch = line.match(/^description:\s*(.+)/);
|
|
230
|
+
if (descMatch) result.description = descMatch[1].trim();
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Generate Tier 2 (discovered) suggestions from unlinked skill artifacts.
|
|
237
|
+
*
|
|
238
|
+
* Any skill artifact that:
|
|
239
|
+
* 1. Has no linked profile (linkedProfileId is null)
|
|
240
|
+
* 2. Has parseable SKILL.md frontmatter with at least a name
|
|
241
|
+
*
|
|
242
|
+
* becomes a suggestion with confidence 0.5 (below the Tier 1 minimum of 0.6).
|
|
243
|
+
*/
|
|
244
|
+
export function generateTier2Suggestions(
|
|
245
|
+
unlinkedArtifacts: EnvironmentArtifactRow[]
|
|
246
|
+
): ProfileSuggestion[] {
|
|
247
|
+
const suggestions: ProfileSuggestion[] = [];
|
|
248
|
+
|
|
249
|
+
for (const artifact of unlinkedArtifacts) {
|
|
250
|
+
if (artifact.category !== "skill") continue;
|
|
251
|
+
|
|
252
|
+
// Try to extract metadata from the artifact's preview or metadata field
|
|
253
|
+
let name: string | undefined;
|
|
254
|
+
let description: string | undefined;
|
|
255
|
+
|
|
256
|
+
// Parse from metadata (JSON) if available
|
|
257
|
+
if (artifact.metadata) {
|
|
258
|
+
try {
|
|
259
|
+
const meta = JSON.parse(artifact.metadata);
|
|
260
|
+
name = meta.name;
|
|
261
|
+
description = meta.description;
|
|
262
|
+
} catch {
|
|
263
|
+
// Fall through to preview parsing
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Fall back to preview (first 200 chars of SKILL.md) frontmatter parsing
|
|
268
|
+
if (!name) {
|
|
269
|
+
const fm = parseFrontmatter(artifact.preview);
|
|
270
|
+
name = fm.name;
|
|
271
|
+
description = fm.description;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// If we still don't have a name, use the artifact name (directory basename)
|
|
275
|
+
if (!name) {
|
|
276
|
+
name = artifact.name
|
|
277
|
+
.replace(/-/g, " ")
|
|
278
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
suggestions.push({
|
|
282
|
+
ruleId: `discovered-${artifact.name}`,
|
|
283
|
+
name,
|
|
284
|
+
description: description ?? `Discovered skill: ${artifact.name}`,
|
|
285
|
+
confidence: TIER2_CONFIDENCE,
|
|
286
|
+
tier: "discovered",
|
|
287
|
+
matchedArtifacts: [
|
|
288
|
+
{ id: artifact.id, name: artifact.name, category: artifact.category },
|
|
289
|
+
],
|
|
290
|
+
suggestedTools: ["Read", "Grep", "Glob", "Bash"],
|
|
291
|
+
systemPrompt: description ?? `You are a ${name} specialist.`,
|
|
292
|
+
tags: artifact.name.split("-").filter((t) => t.length > 2),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return suggestions.sort((a, b) => a.name.localeCompare(b.name));
|
|
297
|
+
}
|