stagent 0.10.0 → 0.11.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 (170) hide show
  1. package/README.md +15 -2
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/index.md +1 -1
  18. package/docs/journeys/developer.md +25 -2
  19. package/docs/journeys/personal-use.md +12 -5
  20. package/docs/journeys/power-user.md +45 -14
  21. package/docs/journeys/work-use.md +17 -8
  22. package/docs/manifest.json +15 -15
  23. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  24. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  25. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  27. package/next.config.mjs +1 -0
  28. package/package.json +1 -1
  29. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  30. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  31. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  32. package/src/app/api/chat/export/route.ts +52 -0
  33. package/src/app/api/chat/files/search/route.ts +50 -0
  34. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  35. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  36. package/src/app/api/environment/skills/route.ts +13 -0
  37. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  38. package/src/app/api/settings/chat/pins/route.ts +94 -0
  39. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  40. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  41. package/src/app/api/settings/environment/route.ts +26 -0
  42. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  43. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  44. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  45. package/src/app/documents/page.tsx +4 -1
  46. package/src/app/settings/page.tsx +2 -0
  47. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  48. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  49. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  50. package/src/components/chat/capability-banner.tsx +68 -0
  51. package/src/components/chat/chat-command-popover.tsx +668 -47
  52. package/src/components/chat/chat-input.tsx +103 -8
  53. package/src/components/chat/chat-message.tsx +12 -3
  54. package/src/components/chat/chat-session-provider.tsx +73 -3
  55. package/src/components/chat/chat-shell.tsx +62 -3
  56. package/src/components/chat/command-tab-bar.tsx +68 -0
  57. package/src/components/chat/conversation-template-picker.tsx +421 -0
  58. package/src/components/chat/help-dialog.tsx +39 -0
  59. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  60. package/src/components/chat/skill-row.tsx +147 -0
  61. package/src/components/documents/document-browser.tsx +37 -19
  62. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  63. package/src/components/notifications/permission-response-actions.tsx +155 -1
  64. package/src/components/settings/environment-section.tsx +102 -0
  65. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  66. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  67. package/src/components/shared/command-palette.tsx +262 -2
  68. package/src/components/shared/filter-hint.tsx +70 -0
  69. package/src/components/shared/filter-input.tsx +59 -0
  70. package/src/components/shared/saved-searches-manager.tsx +199 -0
  71. package/src/components/tasks/task-bento-grid.tsx +12 -2
  72. package/src/components/tasks/task-card.tsx +3 -0
  73. package/src/components/tasks/task-chip-bar.tsx +30 -1
  74. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  75. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  76. package/src/hooks/use-active-skills.ts +110 -0
  77. package/src/hooks/use-chat-autocomplete.ts +120 -7
  78. package/src/hooks/use-enriched-skills.ts +19 -0
  79. package/src/hooks/use-pinned-entries.ts +104 -0
  80. package/src/hooks/use-recent-user-messages.ts +19 -0
  81. package/src/hooks/use-saved-searches.ts +142 -0
  82. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  83. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  84. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  85. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  86. package/src/lib/agents/claude-agent.ts +105 -46
  87. package/src/lib/agents/handoff/bus.ts +2 -2
  88. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  89. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  90. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  91. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  92. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  93. package/src/lib/agents/profiles/registry.ts +18 -0
  94. package/src/lib/agents/profiles/types.ts +7 -1
  95. package/src/lib/agents/router.ts +3 -6
  96. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  97. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  98. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  99. package/src/lib/agents/runtime/catalog.ts +121 -0
  100. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  101. package/src/lib/agents/runtime/execution-target.ts +456 -0
  102. package/src/lib/agents/runtime/index.ts +4 -0
  103. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  104. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  105. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  106. package/src/lib/agents/task-dispatch.ts +220 -0
  107. package/src/lib/agents/tool-permissions.ts +16 -1
  108. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  109. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  110. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  111. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  112. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  113. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  114. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  115. package/src/lib/chat/__tests__/types.test.ts +28 -0
  116. package/src/lib/chat/active-skills.ts +31 -0
  117. package/src/lib/chat/clean-filter-input.ts +30 -0
  118. package/src/lib/chat/codex-engine.ts +30 -7
  119. package/src/lib/chat/command-tabs.ts +61 -0
  120. package/src/lib/chat/context-builder.ts +141 -1
  121. package/src/lib/chat/dismissals.ts +73 -0
  122. package/src/lib/chat/engine.ts +109 -15
  123. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  124. package/src/lib/chat/files/expand-mention.ts +76 -0
  125. package/src/lib/chat/files/search.ts +99 -0
  126. package/src/lib/chat/skill-composition.ts +210 -0
  127. package/src/lib/chat/skill-conflict.ts +105 -0
  128. package/src/lib/chat/stagent-tools.ts +6 -19
  129. package/src/lib/chat/stream-telemetry.ts +9 -4
  130. package/src/lib/chat/system-prompt.ts +22 -0
  131. package/src/lib/chat/tool-catalog.ts +33 -3
  132. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  133. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  134. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  135. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  136. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  137. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  138. package/src/lib/chat/tools/helpers.ts +2 -0
  139. package/src/lib/chat/tools/profile-tools.ts +120 -23
  140. package/src/lib/chat/tools/skill-tools.ts +183 -0
  141. package/src/lib/chat/tools/task-tools.ts +6 -2
  142. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  143. package/src/lib/chat/types.ts +15 -0
  144. package/src/lib/constants/settings.ts +2 -0
  145. package/src/lib/data/clear.ts +2 -6
  146. package/src/lib/db/bootstrap.ts +17 -0
  147. package/src/lib/db/schema.ts +26 -0
  148. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  149. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  150. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  151. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  152. package/src/lib/environment/data.ts +9 -0
  153. package/src/lib/environment/list-skills.ts +176 -0
  154. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  155. package/src/lib/environment/parsers/skill.ts +26 -5
  156. package/src/lib/environment/profile-generator.ts +54 -0
  157. package/src/lib/environment/skill-enrichment.ts +106 -0
  158. package/src/lib/environment/skill-recommendations.ts +66 -0
  159. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  160. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  161. package/src/lib/filters/parse.ts +86 -0
  162. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  163. package/src/lib/instance/fingerprint.ts +7 -9
  164. package/src/lib/instance/upgrade-poller.ts +53 -1
  165. package/src/lib/schedules/scheduler.ts +4 -4
  166. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  167. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  168. package/src/lib/workflows/blueprints/types.ts +6 -0
  169. package/src/lib/workflows/engine.ts +5 -3
  170. package/src/test/setup.ts +10 -0
@@ -1,3 +1,9 @@
1
+ import {
2
+ getRuntimeFeatures,
3
+ resolveAgentRuntime,
4
+ type RuntimeFeatures,
5
+ } from "@/lib/agents/runtime/catalog";
6
+
1
7
  /** Screenshot attachment metadata stored in message metadata.attachments */
2
8
  export interface ScreenshotAttachment {
3
9
  documentId: string;
@@ -107,6 +113,15 @@ export function getRuntimeForModel(modelId: string): string {
107
113
  return /^(gpt|o\d)/.test(modelId) ? "openai-codex-app-server" : "claude-code";
108
114
  }
109
115
 
116
+ /**
117
+ * Model → LLM-surface features. Thin wrapper around getRuntimeForModel +
118
+ * getRuntimeFeatures so chat callers don't need to know runtime IDs.
119
+ */
120
+ export function getFeaturesForModel(modelId: string): RuntimeFeatures {
121
+ const runtimeId = resolveAgentRuntime(getRuntimeForModel(modelId));
122
+ return getRuntimeFeatures(runtimeId);
123
+ }
124
+
110
125
  /** Suggested prompt category with expandable sub-prompts */
111
126
  export interface PromptCategory {
112
127
  id: string;
@@ -27,6 +27,8 @@ export const SETTINGS_KEYS = {
27
27
  SCHEDULE_MAX_CONCURRENT: "schedule.maxConcurrent",
28
28
  SCHEDULE_MAX_RUN_DURATION_SEC: "schedule.maxRunDurationSec",
29
29
  SCHEDULE_CHAT_PRESSURE_DELAY_SEC: "schedule.chatPressureDelaySec",
30
+ // Environment / profile sync
31
+ AUTO_PROMOTE_SKILLS: "environment.autoPromoteSkills",
30
32
  } as const;
31
33
 
32
34
  export type RoutingPreference = "cost" | "latency" | "quality" | "manual";
@@ -55,9 +55,8 @@ const screenshotsDir = join(dataDir, "screenshots");
55
55
 
56
56
  /**
57
57
  * Wipe all data tables (FK-safe order) and uploaded files.
58
- * Preserves the settings table (auth config) and the license table
59
- * (paid tier activation) clearing operational data should never
60
- * silently downgrade a paid instance back to community.
58
+ * Preserves the settings table (auth config) clearing operational
59
+ * data should never silently reset user auth preferences.
61
60
  */
62
61
  export function clearAllData() {
63
62
  const sampleProfilesDeleted = clearSampleProfiles();
@@ -93,9 +92,6 @@ export function clearAllData() {
93
92
  const agentMessagesDeleted = db.delete(agentMessages).run().changes;
94
93
  const channelConfigsDeleted = db.delete(channelConfigs).run().changes;
95
94
 
96
- // License table is intentionally preserved — clearing operational data
97
- // should never downgrade a paid instance back to community tier.
98
-
99
95
  // Snapshots are intentionally preserved — they are backups, not working data
100
96
 
101
97
  const repoImportsDeleted = db.delete(repoImports).run().changes;
@@ -70,6 +70,9 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
70
70
  status TEXT DEFAULT 'planned' NOT NULL,
71
71
  assigned_agent TEXT,
72
72
  agent_profile TEXT,
73
+ effective_runtime_id TEXT,
74
+ effective_model_id TEXT,
75
+ runtime_fallback_reason TEXT,
73
76
  priority INTEGER DEFAULT 2 NOT NULL,
74
77
  result TEXT,
75
78
  session_id TEXT,
@@ -303,6 +306,9 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
303
306
 
304
307
  addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
305
308
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
309
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN effective_runtime_id TEXT;`);
310
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN effective_model_id TEXT;`);
311
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN runtime_fallback_reason TEXT;`);
306
312
 
307
313
  addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
308
314
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
@@ -333,6 +339,15 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
333
339
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN source TEXT DEFAULT 'upload';`);
334
340
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN conversation_id TEXT REFERENCES conversations(id);`);
335
341
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN message_id TEXT;`);
342
+ // chat-ollama-native-skills: conversation-scoped active skill binding.
343
+ // Ollama can't use the SDK's native skill support, so we inject the
344
+ // selected skill's SKILL.md into Tier 0 of the system prompt on every
345
+ // turn while this column is set. Same machinery is usable from Claude
346
+ // and Codex as a programmatic skill-activation path.
347
+ addColumnIfMissing(`ALTER TABLE conversations ADD COLUMN active_skill_id TEXT;`);
348
+ // chat-skill-composition v1: array of additionally-activated skill IDs
349
+ // beyond the legacy active_skill_id. Default empty JSON array.
350
+ addColumnIfMissing(`ALTER TABLE conversations ADD COLUMN active_skill_ids TEXT DEFAULT '[]';`);
336
351
  // Workflow step delays — resume_at for schedule-based delay resumption.
337
352
  // The partial index on resume_at is created by migration 0024 for fresh DBs;
338
353
  // existing DBs that don't run migrations will do a small table scan instead.
@@ -455,6 +470,8 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
455
470
  status TEXT DEFAULT 'active' NOT NULL,
456
471
  session_id TEXT,
457
472
  context_scope TEXT,
473
+ active_skill_id TEXT,
474
+ active_skill_ids TEXT DEFAULT '[]',
458
475
  created_at INTEGER NOT NULL,
459
476
  updated_at INTEGER NOT NULL,
460
477
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -29,6 +29,12 @@ export const tasks = sqliteTable(
29
29
  .notNull(),
30
30
  assignedAgent: text("assigned_agent"),
31
31
  agentProfile: text("agent_profile"),
32
+ /** Runtime actually used for the most recent execution attempt. */
33
+ effectiveRuntimeId: text("effective_runtime_id"),
34
+ /** Model actually used for the most recent execution attempt. */
35
+ effectiveModelId: text("effective_model_id"),
36
+ /** Human-readable reason when execution fell back from the requested runtime/model. */
37
+ runtimeFallbackReason: text("runtime_fallback_reason"),
32
38
  priority: integer("priority").default(2).notNull(),
33
39
  result: text("result"),
34
40
  sessionId: text("session_id"),
@@ -542,6 +548,26 @@ export const conversations = sqliteTable(
542
548
  .notNull(),
543
549
  sessionId: text("session_id"),
544
550
  contextScope: text("context_scope"), // JSON: context config overrides
551
+ /**
552
+ * Opaque skill ID of the Stagent-activated skill for this conversation.
553
+ * When set, the context builder injects that skill's SKILL.md into the
554
+ * Tier 0 system prompt every turn. Primary use case is Ollama (no
555
+ * SDK-native skill support); Claude and Codex can also use it as a
556
+ * programmatic skill-activation path alongside their native Skill tools.
557
+ *
558
+ * See `features/chat-ollama-native-skills.md`.
559
+ */
560
+ activeSkillId: text("active_skill_id"),
561
+ /**
562
+ * Composition v1 — array of additionally-activated skill IDs (beyond
563
+ * the legacy `activeSkillId`). Default `[]`. Read paths merge legacy
564
+ * + new and dedupe via `mergeActiveSkillIds`. Stored as JSON text.
565
+ *
566
+ * See `features/chat-skill-composition.md`.
567
+ */
568
+ activeSkillIds: text("active_skill_ids", { mode: "json" })
569
+ .$type<string[]>()
570
+ .default([] as unknown as string[]),
545
571
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
546
572
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
547
573
  },
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("@/lib/settings/helpers", () => ({
4
+ getSettingSync: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../profile-linker", () => ({
8
+ linkArtifactsToProfiles: vi.fn(),
9
+ }));
10
+
11
+ // The module under test depends on suggestProfilesTiered + createProfileFromSuggestion,
12
+ // which live in the same file. We test via the real function but stub its
13
+ // collaborators (getArtifacts + listProfiles) through their imported modules.
14
+ vi.mock("../data", () => ({
15
+ getArtifacts: vi.fn(() => []),
16
+ }));
17
+
18
+ vi.mock("@/lib/agents/profiles/registry", () => ({
19
+ listProfiles: vi.fn(() => []),
20
+ createProfile: vi.fn(),
21
+ }));
22
+
23
+ import { autoPromoteUnlinkedSkills } from "../profile-generator";
24
+ import { getSettingSync } from "@/lib/settings/helpers";
25
+ import { linkArtifactsToProfiles } from "../profile-linker";
26
+ import { getArtifacts } from "../data";
27
+ import { createProfile } from "@/lib/agents/profiles/registry";
28
+
29
+ const mockGetSettingSync = getSettingSync as ReturnType<typeof vi.fn>;
30
+ const mockLinker = linkArtifactsToProfiles as ReturnType<typeof vi.fn>;
31
+ const mockGetArtifacts = getArtifacts as ReturnType<typeof vi.fn>;
32
+ const mockCreateProfile = createProfile as ReturnType<typeof vi.fn>;
33
+
34
+ function unlinkedSkill(name: string) {
35
+ return {
36
+ id: `art-${name}`,
37
+ scanId: "scan-1",
38
+ tool: "claude-code",
39
+ category: "skill",
40
+ scope: "user",
41
+ name,
42
+ relPath: `${name}/SKILL.md`,
43
+ absPath: `/home/u/.claude/skills/${name}/SKILL.md`,
44
+ contentHash: "abc",
45
+ preview: `---\nname: ${name}\ndescription: A ${name} skill\n---\n`,
46
+ metadata: null,
47
+ sizeBytes: 100,
48
+ modifiedAt: new Date(),
49
+ createdAt: new Date(),
50
+ linkedProfileId: null,
51
+ };
52
+ }
53
+
54
+ describe("autoPromoteUnlinkedSkills", () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ });
58
+
59
+ it("returns empty result when setting is disabled", () => {
60
+ mockGetSettingSync.mockReturnValue("false");
61
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("alpha")]);
62
+
63
+ const result = autoPromoteUnlinkedSkills("scan-1");
64
+
65
+ expect(result.created).toEqual([]);
66
+ expect(mockCreateProfile).not.toHaveBeenCalled();
67
+ expect(mockLinker).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("returns empty result when setting is missing (default off)", () => {
71
+ mockGetSettingSync.mockReturnValue(null);
72
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("alpha")]);
73
+
74
+ const result = autoPromoteUnlinkedSkills("scan-1");
75
+
76
+ expect(result.created).toEqual([]);
77
+ expect(mockCreateProfile).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("creates profiles for every Tier 2 suggestion and re-links when enabled", () => {
81
+ mockGetSettingSync.mockReturnValue("true");
82
+ mockGetArtifacts.mockReturnValue([
83
+ unlinkedSkill("alpha"),
84
+ unlinkedSkill("beta"),
85
+ ]);
86
+
87
+ const result = autoPromoteUnlinkedSkills("scan-1");
88
+
89
+ expect(mockCreateProfile).toHaveBeenCalledTimes(2);
90
+ expect(result.created).toHaveLength(2);
91
+ expect(result.errors).toHaveLength(0);
92
+ expect(mockLinker).toHaveBeenCalledWith("scan-1");
93
+ });
94
+
95
+ it("counts 'already exists' failures as skipped, not errors", () => {
96
+ mockGetSettingSync.mockReturnValue("true");
97
+ mockGetArtifacts.mockReturnValue([unlinkedSkill("gamma")]);
98
+ mockCreateProfile.mockImplementationOnce(() => {
99
+ throw new Error("profile already exists");
100
+ });
101
+
102
+ const result = autoPromoteUnlinkedSkills("scan-1");
103
+
104
+ expect(result.created).toEqual([]);
105
+ expect(result.skipped).toHaveLength(1);
106
+ expect(result.errors).toEqual([]);
107
+ // No re-link when nothing was created
108
+ expect(mockLinker).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("records non-duplicate errors and still re-links when some profiles succeeded", () => {
112
+ mockGetSettingSync.mockReturnValue("true");
113
+ mockGetArtifacts.mockReturnValue([
114
+ unlinkedSkill("delta"),
115
+ unlinkedSkill("epsilon"),
116
+ ]);
117
+ mockCreateProfile
118
+ .mockImplementationOnce(() => {
119
+ /* succeeds */
120
+ })
121
+ .mockImplementationOnce(() => {
122
+ throw new Error("disk full");
123
+ });
124
+
125
+ const result = autoPromoteUnlinkedSkills("scan-1");
126
+
127
+ expect(result.created).toHaveLength(1);
128
+ expect(result.errors).toHaveLength(1);
129
+ expect(result.errors[0].message).toBe("disk full");
130
+ expect(mockLinker).toHaveBeenCalledWith("scan-1");
131
+ });
132
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ vi.mock("../data", () => ({
4
+ getLatestScan: () => ({ id: "scan-1" }),
5
+ getArtifacts: () => [
6
+ {
7
+ id: "art-1",
8
+ scanId: "scan-1",
9
+ category: "skill",
10
+ tool: "claude-code",
11
+ scope: "user",
12
+ name: "code-reviewer",
13
+ relPath: ".claude/skills/code-reviewer",
14
+ absPath: "/u/.claude/skills/code-reviewer",
15
+ preview: "Review PRs",
16
+ sizeBytes: 100,
17
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
18
+ linkedProfileId: "code-reviewer-profile",
19
+ contentHash: "h",
20
+ metadata: null,
21
+ createdAt: new Date(),
22
+ },
23
+ {
24
+ id: "art-2",
25
+ scanId: "scan-1",
26
+ category: "skill",
27
+ tool: "codex",
28
+ scope: "user",
29
+ name: "code-reviewer",
30
+ relPath: ".agents/skills/code-reviewer",
31
+ absPath: "/u/.agents/skills/code-reviewer",
32
+ preview: "Review PRs",
33
+ sizeBytes: 100,
34
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
35
+ linkedProfileId: null,
36
+ contentHash: "h",
37
+ metadata: null,
38
+ createdAt: new Date(),
39
+ },
40
+ ],
41
+ }));
42
+
43
+ import { listSkillsEnriched } from "../list-skills";
44
+
45
+ describe("listSkillsEnriched", () => {
46
+ it("returns enriched skills with syncStatus and linkedProfileId populated", () => {
47
+ const nowMs = new Date("2026-04-14T00:00:00Z").getTime();
48
+ const enriched = listSkillsEnriched({ nowMs });
49
+ expect(enriched).toHaveLength(1);
50
+ expect(enriched[0].name).toBe("code-reviewer");
51
+ expect(enriched[0].syncStatus).toBe("synced");
52
+ expect(enriched[0].linkedProfileId).toBe("code-reviewer-profile");
53
+ expect(enriched[0].healthScore).toBe("healthy");
54
+ });
55
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ computeHealthScore,
4
+ computeSyncStatus,
5
+ type HealthScore,
6
+ type SyncStatus,
7
+ } from "../skill-enrichment";
8
+
9
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
10
+
11
+ describe("computeHealthScore", () => {
12
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
13
+
14
+ it("returns 'healthy' for artifacts modified in the last 6 months", () => {
15
+ expect(computeHealthScore(NOW - 30 * MS_PER_DAY, NOW)).toBe("healthy");
16
+ expect(computeHealthScore(NOW - 179 * MS_PER_DAY, NOW)).toBe("healthy");
17
+ });
18
+
19
+ it("returns 'stale' for artifacts between 6 and 12 months old", () => {
20
+ expect(computeHealthScore(NOW - 200 * MS_PER_DAY, NOW)).toBe("stale");
21
+ expect(computeHealthScore(NOW - 364 * MS_PER_DAY, NOW)).toBe("stale");
22
+ });
23
+
24
+ it("returns 'aging' for artifacts over 12 months old", () => {
25
+ expect(computeHealthScore(NOW - 400 * MS_PER_DAY, NOW)).toBe("aging");
26
+ });
27
+
28
+ it("returns 'unknown' when modifiedAt is null", () => {
29
+ expect(computeHealthScore(null, NOW)).toBe("unknown");
30
+ });
31
+ });
32
+
33
+ describe("computeSyncStatus", () => {
34
+ it("returns 'synced' when both tools have the skill", () => {
35
+ expect(computeSyncStatus(["claude-code", "codex"])).toBe("synced");
36
+ });
37
+
38
+ it("returns 'claude-only' when only claude-code has it", () => {
39
+ expect(computeSyncStatus(["claude-code"])).toBe("claude-only");
40
+ });
41
+
42
+ it("returns 'codex-only' when only codex has it", () => {
43
+ expect(computeSyncStatus(["codex"])).toBe("codex-only");
44
+ });
45
+
46
+ it("returns 'shared' when only shared tool is present", () => {
47
+ expect(computeSyncStatus(["shared"])).toBe("shared");
48
+ });
49
+
50
+ it("returns 'synced' when claude + shared", () => {
51
+ expect(computeSyncStatus(["claude-code", "shared"])).toBe("synced");
52
+ });
53
+ });
54
+
55
+ import { enrichSkills, type EnrichedSkill } from "../skill-enrichment";
56
+ import type { SkillSummary } from "../list-skills";
57
+
58
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
59
+ const DAY = 24 * 60 * 60 * 1000;
60
+
61
+ function skill(
62
+ id: string,
63
+ name: string,
64
+ tool: string,
65
+ overrides: Partial<SkillSummary> = {}
66
+ ): SkillSummary {
67
+ return {
68
+ id,
69
+ name,
70
+ tool,
71
+ scope: "user",
72
+ preview: "",
73
+ sizeBytes: 0,
74
+ absPath: `/tmp/${id}`,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe("enrichSkills", () => {
80
+ it("groups by name and computes syncStatus across tools", () => {
81
+ const out = enrichSkills(
82
+ [
83
+ skill("a", "research", "claude-code"),
84
+ skill("b", "research", "codex"),
85
+ skill("c", "standalone", "claude-code"),
86
+ ],
87
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
88
+ );
89
+ const bySkill: Record<string, EnrichedSkill> = {};
90
+ for (const s of out) bySkill[s.name] = s;
91
+ expect(bySkill.research.syncStatus).toBe("synced");
92
+ expect(bySkill.standalone.syncStatus).toBe("claude-only");
93
+ });
94
+
95
+ it("attaches linkedProfileId per artifact absPath", () => {
96
+ const out = enrichSkills(
97
+ [skill("x", "coder", "claude-code", { absPath: "/p/a" })],
98
+ {
99
+ modifiedAtMsByPath: {},
100
+ linkedProfilesByPath: { "/p/a": "code-reviewer" },
101
+ nowMs: NOW,
102
+ }
103
+ );
104
+ expect(out[0].linkedProfileId).toBe("code-reviewer");
105
+ });
106
+
107
+ it("assigns health from modifiedAtMsByPath", () => {
108
+ const out = enrichSkills(
109
+ [skill("x", "aging", "claude-code", { absPath: "/p/a" })],
110
+ {
111
+ modifiedAtMsByPath: { "/p/a": NOW - 400 * DAY },
112
+ linkedProfilesByPath: {},
113
+ nowMs: NOW,
114
+ }
115
+ );
116
+ expect(out[0].healthScore).toBe("aging");
117
+ });
118
+
119
+ it("merges duplicate absPaths (symlink case) to a single entry", () => {
120
+ const out = enrichSkills(
121
+ [
122
+ skill("a", "shared", "claude-code", { absPath: "/same" }),
123
+ skill("b", "shared", "codex", { absPath: "/same" }),
124
+ ],
125
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
126
+ );
127
+ expect(out).toHaveLength(1);
128
+ });
129
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeRecommendation } from "../skill-recommendations";
3
+ import type { EnrichedSkill } from "../skill-enrichment";
4
+
5
+ const mkSkill = (
6
+ name: string,
7
+ preview: string,
8
+ overrides: Partial<EnrichedSkill> = {}
9
+ ): EnrichedSkill => ({
10
+ id: name,
11
+ name,
12
+ tool: "claude-code",
13
+ scope: "user",
14
+ preview,
15
+ sizeBytes: 0,
16
+ absPath: `/p/${name}`,
17
+ healthScore: "healthy",
18
+ syncStatus: "claude-only",
19
+ linkedProfileId: null,
20
+ absPaths: [`/p/${name}`],
21
+ ...overrides,
22
+ });
23
+
24
+ describe("computeRecommendation", () => {
25
+ it("recommends a healthy skill whose keywords match 2+ in recent messages", () => {
26
+ const skills = [
27
+ mkSkill("code-reviewer", "Review pull requests for security"),
28
+ mkSkill("researcher", "Search the web for up-to-date information"),
29
+ ];
30
+ const rec = computeRecommendation(skills, [
31
+ "can you review this pull request for security issues?",
32
+ ]);
33
+ expect(rec?.name).toBe("code-reviewer");
34
+ });
35
+
36
+ it("returns null when no strong keyword match exists", () => {
37
+ const skills = [mkSkill("code-reviewer", "Review PRs for security")];
38
+ const rec = computeRecommendation(skills, ["hi there"]);
39
+ expect(rec).toBeNull();
40
+ });
41
+
42
+ it("excludes already-active skill", () => {
43
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
44
+ const rec = computeRecommendation(
45
+ skills,
46
+ ["review this pull request for security"],
47
+ { activeSkillId: "code-reviewer" }
48
+ );
49
+ expect(rec).toBeNull();
50
+ });
51
+
52
+ it("excludes dismissed skills", () => {
53
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
54
+ const rec = computeRecommendation(
55
+ skills,
56
+ ["review pull request security issues"],
57
+ { dismissedIds: new Set(["code-reviewer"]) }
58
+ );
59
+ expect(rec).toBeNull();
60
+ });
61
+
62
+ it("excludes broken/aging skills", () => {
63
+ const skills = [
64
+ mkSkill("code-reviewer", "Review pull requests security", {
65
+ healthScore: "aging",
66
+ }),
67
+ ];
68
+ const rec = computeRecommendation(skills, [
69
+ "review pull request security issues",
70
+ ]);
71
+ expect(rec).toBeNull();
72
+ });
73
+
74
+ it("ignores stopwords and requires ≥2 distinct meaningful hits", () => {
75
+ const skills = [mkSkill("researcher", "the and for a of in on")];
76
+ const rec = computeRecommendation(skills, ["the and for a of in on"]);
77
+ expect(rec).toBeNull();
78
+ });
79
+
80
+ it("returns null on empty message list", () => {
81
+ const rec = computeRecommendation(
82
+ [mkSkill("code-reviewer", "review pull request security")],
83
+ []
84
+ );
85
+ expect(rec).toBeNull();
86
+ });
87
+ });
@@ -78,6 +78,15 @@ export function createScan(
78
78
  console.warn("[environment] Profile linking failed (non-blocking):", err);
79
79
  }
80
80
 
81
+ // Auto-promote unlinked skills to profiles if the user opted in.
82
+ // Imported lazily to avoid a top-level circular import with profile-generator.ts.
83
+ // Fire-and-forget: auto-promote runs asynchronously and failures are logged only.
84
+ import("./profile-generator")
85
+ .then((m) => m.autoPromoteUnlinkedSkills(scanId))
86
+ .catch((err) =>
87
+ console.warn("[environment] Auto-promote failed (non-blocking):", err)
88
+ );
89
+
81
90
  return db
82
91
  .select()
83
92
  .from(environmentScans)