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.
- package/README.md +15 -2
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +1 -1
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +18 -0
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +54 -0
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +7 -9
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
package/src/lib/chat/types.ts
CHANGED
|
@@ -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";
|
package/src/lib/data/clear.ts
CHANGED
|
@@ -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)
|
|
59
|
-
*
|
|
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;
|
package/src/lib/db/bootstrap.ts
CHANGED
|
@@ -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
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -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)
|