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.
Files changed (252) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +146 -2
  3. package/docs/.coverage-gaps.json +21 -0
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +36 -14
  6. package/docs/features/chat.md +33 -56
  7. package/docs/features/cost-usage.md +14 -10
  8. package/docs/features/dashboard-kanban.md +30 -13
  9. package/docs/features/delivery-channels.md +198 -0
  10. package/docs/features/design-system.md +10 -10
  11. package/docs/features/documents.md +8 -8
  12. package/docs/features/home-workspace.md +20 -15
  13. package/docs/features/inbox-notifications.md +22 -10
  14. package/docs/features/keyboard-navigation.md +11 -11
  15. package/docs/features/monitoring.md +1 -1
  16. package/docs/features/playbook.md +30 -32
  17. package/docs/features/profiles.md +33 -11
  18. package/docs/features/projects.md +2 -2
  19. package/docs/features/provider-runtimes.md +58 -14
  20. package/docs/features/schedules.md +70 -40
  21. package/docs/features/settings.md +74 -46
  22. package/docs/features/shared-components.md +7 -15
  23. package/docs/features/tool-permissions.md +9 -9
  24. package/docs/features/workflows.md +32 -21
  25. package/docs/getting-started.md +33 -9
  26. package/docs/index.md +25 -16
  27. package/docs/journeys/developer.md +124 -207
  28. package/docs/journeys/personal-use.md +70 -79
  29. package/docs/journeys/power-user.md +107 -151
  30. package/docs/journeys/work-use.md +81 -113
  31. package/docs/manifest.json +77 -45
  32. package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
  33. package/docs/use-cases/agency-operator.md +84 -0
  34. package/docs/use-cases/solo-founder.md +75 -0
  35. package/docs/why-stagent.md +59 -0
  36. package/package.json +10 -3
  37. package/src/app/api/channels/[id]/route.ts +103 -0
  38. package/src/app/api/channels/[id]/test/route.ts +52 -0
  39. package/src/app/api/channels/inbound/slack/route.ts +109 -0
  40. package/src/app/api/channels/inbound/telegram/poll/route.ts +128 -0
  41. package/src/app/api/channels/inbound/telegram/route.ts +76 -0
  42. package/src/app/api/channels/route.ts +71 -0
  43. package/src/app/api/chat/conversations/route.ts +15 -0
  44. package/src/app/api/chat/entities/search/route.ts +46 -31
  45. package/src/app/api/environment/profiles/suggest/route.ts +19 -3
  46. package/src/app/api/environment/scan/route.ts +8 -1
  47. package/src/app/api/handoffs/[id]/route.ts +76 -0
  48. package/src/app/api/handoffs/route.ts +89 -0
  49. package/src/app/api/memory/route.ts +181 -0
  50. package/src/app/api/profiles/[id]/route.ts +16 -1
  51. package/src/app/api/profiles/[id]/test/route.ts +4 -0
  52. package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
  53. package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
  54. package/src/app/api/profiles/assist/route.ts +35 -0
  55. package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
  56. package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
  57. package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
  58. package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
  59. package/src/app/api/profiles/import-repo/route.ts +29 -0
  60. package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
  61. package/src/app/api/profiles/route.ts +73 -22
  62. package/src/app/api/runtimes/ollama/route.ts +86 -0
  63. package/src/app/api/runtimes/suggest/route.ts +29 -0
  64. package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
  65. package/src/app/api/schedules/[id]/route.ts +41 -3
  66. package/src/app/api/schedules/parse/route.ts +66 -0
  67. package/src/app/api/schedules/route.ts +71 -12
  68. package/src/app/api/settings/author-default/route.ts +7 -0
  69. package/src/app/api/settings/learning/route.ts +41 -0
  70. package/src/app/api/settings/ollama/route.ts +34 -0
  71. package/src/app/api/settings/providers/route.ts +57 -0
  72. package/src/app/api/settings/routing/route.ts +24 -0
  73. package/src/app/api/settings/web-search/route.ts +28 -0
  74. package/src/app/api/tasks/[id]/execute/route.ts +13 -1
  75. package/src/app/documents/page.tsx +3 -0
  76. package/src/app/environment/page.tsx +8 -1
  77. package/src/app/settings/page.tsx +10 -4
  78. package/src/app/workflows/[id]/edit/page.tsx +2 -0
  79. package/src/app/workflows/new/page.tsx +2 -0
  80. package/src/components/chat/chat-command-popover.tsx +22 -19
  81. package/src/components/chat/chat-input.tsx +5 -0
  82. package/src/components/chat/chat-model-selector.tsx +42 -1
  83. package/src/components/chat/chat-shell.tsx +2 -0
  84. package/src/components/dashboard/welcome-landing.tsx +9 -9
  85. package/src/components/environment/artifact-card.tsx +27 -1
  86. package/src/components/environment/environment-dashboard.tsx +50 -2
  87. package/src/components/environment/environment-summary-card.tsx +5 -2
  88. package/src/components/environment/suggested-profiles.tsx +117 -52
  89. package/src/components/handoffs/handoff-approval-card.tsx +159 -0
  90. package/src/components/memory/memory-browser.tsx +315 -0
  91. package/src/components/profiles/learned-context-panel.tsx +4 -4
  92. package/src/components/profiles/profile-assist-panel.tsx +512 -0
  93. package/src/components/profiles/profile-browser.tsx +109 -8
  94. package/src/components/profiles/profile-card.tsx +29 -1
  95. package/src/components/profiles/profile-detail-view.tsx +200 -28
  96. package/src/components/profiles/profile-form-view.tsx +220 -82
  97. package/src/components/profiles/repo-import-wizard.tsx +648 -0
  98. package/src/components/profiles/smoke-test-editor.tsx +106 -0
  99. package/src/components/schedules/schedule-create-sheet.tsx +9 -1
  100. package/src/components/schedules/schedule-form.tsx +348 -9
  101. package/src/components/schedules/schedule-list.tsx +15 -2
  102. package/src/components/settings/auth-method-selector.tsx +7 -1
  103. package/src/components/settings/budget-guardrails-section.tsx +111 -48
  104. package/src/components/settings/channels-section.tsx +526 -0
  105. package/src/components/settings/chat-settings-section.tsx +27 -1
  106. package/src/components/settings/data-management-section.tsx +8 -6
  107. package/src/components/settings/learning-context-section.tsx +124 -0
  108. package/src/components/settings/ollama-section.tsx +270 -0
  109. package/src/components/settings/providers-runtimes-section.tsx +499 -0
  110. package/src/components/settings/web-search-section.tsx +101 -0
  111. package/src/components/shared/tag-input.tsx +156 -0
  112. package/src/components/tasks/kanban-board.tsx +32 -0
  113. package/src/components/tasks/kanban-column.tsx +4 -2
  114. package/src/components/tasks/task-card.tsx +1 -0
  115. package/src/components/tasks/task-chip-bar.tsx +6 -1
  116. package/src/components/tasks/task-create-panel.tsx +55 -5
  117. package/src/components/workflows/workflow-form-view.tsx +38 -3
  118. package/src/hooks/use-chat-autocomplete.ts +24 -26
  119. package/src/hooks/use-project-skills.ts +66 -0
  120. package/src/hooks/use-tag-suggestions.ts +31 -0
  121. package/src/instrumentation.ts +4 -1
  122. package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
  123. package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
  124. package/src/lib/agents/agentic-loop.ts +235 -0
  125. package/src/lib/agents/browser-mcp.ts +59 -4
  126. package/src/lib/agents/claude-agent.ts +26 -199
  127. package/src/lib/agents/handoff/bus.ts +164 -0
  128. package/src/lib/agents/handoff/governance.ts +47 -0
  129. package/src/lib/agents/handoff/types.ts +16 -0
  130. package/src/lib/agents/learned-context.ts +27 -7
  131. package/src/lib/agents/memory/decay.ts +61 -0
  132. package/src/lib/agents/memory/extractor.ts +181 -0
  133. package/src/lib/agents/memory/retrieval.ts +96 -0
  134. package/src/lib/agents/memory/types.ts +6 -0
  135. package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
  136. package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
  137. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
  138. package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
  139. package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
  140. package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
  141. package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
  142. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
  143. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
  144. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
  145. package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
  146. package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
  147. package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
  148. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
  149. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
  150. package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
  151. package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
  152. package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
  153. package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
  154. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
  155. package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
  156. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
  157. package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
  158. package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
  159. package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
  160. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
  161. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
  162. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
  163. package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
  164. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
  165. package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
  166. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
  167. package/src/lib/agents/profiles/project-profiles.ts +193 -0
  168. package/src/lib/agents/profiles/registry.ts +130 -6
  169. package/src/lib/agents/profiles/types.ts +28 -0
  170. package/src/lib/agents/router.ts +174 -2
  171. package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
  172. package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
  173. package/src/lib/agents/runtime/catalog.ts +57 -2
  174. package/src/lib/agents/runtime/claude.ts +205 -1
  175. package/src/lib/agents/runtime/index.ts +22 -0
  176. package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
  177. package/src/lib/agents/runtime/openai-direct.ts +514 -0
  178. package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
  179. package/src/lib/agents/runtime/types.ts +2 -0
  180. package/src/lib/agents/tool-permissions.ts +203 -0
  181. package/src/lib/channels/gateway.ts +321 -0
  182. package/src/lib/channels/poller.ts +268 -0
  183. package/src/lib/channels/registry.ts +90 -0
  184. package/src/lib/channels/slack-adapter.ts +188 -0
  185. package/src/lib/channels/telegram-adapter.ts +218 -0
  186. package/src/lib/channels/types.ts +43 -0
  187. package/src/lib/channels/webhook-adapter.ts +74 -0
  188. package/src/lib/chat/context-builder.ts +22 -2
  189. package/src/lib/chat/engine.ts +95 -13
  190. package/src/lib/chat/ollama-engine.ts +198 -0
  191. package/src/lib/chat/stagent-tools.ts +106 -20
  192. package/src/lib/chat/tool-catalog.ts +24 -0
  193. package/src/lib/chat/tool-registry.ts +90 -0
  194. package/src/lib/chat/tools/chat-history-tools.ts +4 -4
  195. package/src/lib/chat/tools/document-tools.ts +7 -7
  196. package/src/lib/chat/tools/handoff-tools.ts +70 -0
  197. package/src/lib/chat/tools/notification-tools.ts +4 -4
  198. package/src/lib/chat/tools/profile-tools.ts +3 -3
  199. package/src/lib/chat/tools/project-tools.ts +3 -3
  200. package/src/lib/chat/tools/schedule-tools.ts +29 -13
  201. package/src/lib/chat/tools/settings-tools.ts +2 -2
  202. package/src/lib/chat/tools/task-tools.ts +66 -11
  203. package/src/lib/chat/tools/usage-tools.ts +2 -2
  204. package/src/lib/chat/tools/workflow-tools.ts +8 -8
  205. package/src/lib/chat/types.ts +11 -5
  206. package/src/lib/constants/known-tools.ts +19 -0
  207. package/src/lib/constants/prose-styles.ts +1 -1
  208. package/src/lib/constants/settings.ts +7 -0
  209. package/src/lib/data/channel-bindings.ts +85 -0
  210. package/src/lib/data/clear.ts +22 -0
  211. package/src/lib/data/profile-test-results.ts +48 -0
  212. package/src/lib/data/seed-data/conversations.ts +196 -0
  213. package/src/lib/data/seed-data/learned-context.ts +99 -0
  214. package/src/lib/data/seed-data/notifications.ts +54 -1
  215. package/src/lib/data/seed-data/profile-test-results.ts +96 -0
  216. package/src/lib/data/seed-data/repo-imports.ts +51 -0
  217. package/src/lib/data/seed-data/views.ts +60 -0
  218. package/src/lib/data/seed.ts +51 -0
  219. package/src/lib/db/bootstrap.ts +162 -0
  220. package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
  221. package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
  222. package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
  223. package/src/lib/db/schema.ts +187 -1
  224. package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
  225. package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
  226. package/src/lib/environment/auto-scan.ts +48 -0
  227. package/src/lib/environment/data.ts +25 -0
  228. package/src/lib/environment/profile-generator.ts +40 -10
  229. package/src/lib/environment/profile-linker.ts +143 -0
  230. package/src/lib/environment/profile-rules.ts +96 -0
  231. package/src/lib/import/dedup.ts +149 -0
  232. package/src/lib/import/format-adapter.ts +631 -0
  233. package/src/lib/import/github-api.ts +219 -0
  234. package/src/lib/import/repo-scanner.ts +251 -0
  235. package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
  236. package/src/lib/schedules/active-hours.ts +120 -0
  237. package/src/lib/schedules/heartbeat-parser.ts +224 -0
  238. package/src/lib/schedules/heartbeat-prompt.ts +153 -0
  239. package/src/lib/schedules/nlp-parser.ts +357 -0
  240. package/src/lib/schedules/scheduler.ts +218 -3
  241. package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
  242. package/src/lib/settings/helpers.ts +6 -0
  243. package/src/lib/settings/routing.ts +24 -0
  244. package/src/lib/settings/runtime-setup.ts +28 -1
  245. package/src/lib/usage/ledger.ts +2 -1
  246. package/src/lib/validators/__tests__/settings.test.ts +9 -0
  247. package/src/lib/validators/profile.ts +39 -0
  248. package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
  249. package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
  250. package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
  251. package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
  252. 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 MIN_CONFIDENCE = 0.6;
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 suggestProfiles(scanId: string): ProfileSuggestion[] {
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
- // Filter out profiles that already exist (by ID match)
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
- return confident.filter(
29
- (s) => !existingIds.has(s.ruleId) && !existingNames.has(s.name.toLowerCase())
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
+ }