stagent 0.10.0 → 0.11.1
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 +44 -31
- 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/getting-started.md +1 -1
- 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-07-instance-bootstrap.md +2 -2
- 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 +3 -3
- 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/book/content-blocks.tsx +1 -1
- 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/playbook/playbook-detail-view.tsx +1 -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 +97 -22
- 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 +56 -2
- 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__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- 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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { scanEnvironment } from "./scanner";
|
|
4
|
+
import { getLaunchCwd } from "./workspace-context";
|
|
5
|
+
import type { EnvironmentArtifact } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface SkillSummary {
|
|
8
|
+
/** Stable opaque ID used by activate_skill. Today: the relative path. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Short display name, e.g. "capture" or "code-review". */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Which tool persona (claude-code | codex | shared). */
|
|
13
|
+
tool: string;
|
|
14
|
+
/** "user" or "project". */
|
|
15
|
+
scope: string;
|
|
16
|
+
/** Short description (first ~200 chars of SKILL.md body). */
|
|
17
|
+
preview: string;
|
|
18
|
+
sizeBytes: number;
|
|
19
|
+
/** Absolute path to the skill's SKILL.md — consumers can read it. */
|
|
20
|
+
absPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Discover skills from both `.claude/skills/` and `.agents/skills/` under
|
|
25
|
+
* the active project and user home. Reuses the existing environment
|
|
26
|
+
* scanner — we're just filtering to `category === "skill"`.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately narrow surface: the scanner returns many artifact
|
|
29
|
+
* categories; the skill MCP tools only care about skills.
|
|
30
|
+
*/
|
|
31
|
+
export function listSkills(
|
|
32
|
+
options: { projectDir?: string } = {}
|
|
33
|
+
): SkillSummary[] {
|
|
34
|
+
const projectDir = options.projectDir ?? getLaunchCwd();
|
|
35
|
+
const scan = scanEnvironment({ projectDir });
|
|
36
|
+
const skills: SkillSummary[] = [];
|
|
37
|
+
for (const a of scan.artifacts) {
|
|
38
|
+
if (a.category !== "skill") continue;
|
|
39
|
+
// Skip dot-prefixed system dirs (e.g. ~/.codex/skills/.system) —
|
|
40
|
+
// internal artifacts, not user-facing skills.
|
|
41
|
+
if (a.name.startsWith(".")) continue;
|
|
42
|
+
skills.push(artifactToSummary(a));
|
|
43
|
+
}
|
|
44
|
+
// Stable sort: tool, then scope, then name — deterministic listing is
|
|
45
|
+
// easier for the LLM to reason over.
|
|
46
|
+
skills.sort((a, b) => {
|
|
47
|
+
if (a.tool !== b.tool) return a.tool.localeCompare(b.tool);
|
|
48
|
+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
|
|
49
|
+
return a.name.localeCompare(b.name);
|
|
50
|
+
});
|
|
51
|
+
return skills;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Locate a single skill by its opaque ID (the relative path) and return
|
|
56
|
+
* its full SKILL.md content. Returns `null` if the skill is not found
|
|
57
|
+
* or its content can't be read.
|
|
58
|
+
*
|
|
59
|
+
* NB: the artifact's `absPath` is the skill **directory**, not the
|
|
60
|
+
* SKILL.md file. We probe the conventional names (SKILL.md / skill.md)
|
|
61
|
+
* and fall back to the first .md file in the directory.
|
|
62
|
+
*/
|
|
63
|
+
export function getSkill(
|
|
64
|
+
id: string,
|
|
65
|
+
options: { projectDir?: string } = {}
|
|
66
|
+
): (SkillSummary & { content: string }) | null {
|
|
67
|
+
const all = listSkills(options);
|
|
68
|
+
const hit = all.find((s) => s.id === id);
|
|
69
|
+
if (!hit) return null;
|
|
70
|
+
|
|
71
|
+
const filePath = resolveSkillFile(hit.absPath);
|
|
72
|
+
if (!filePath) return null;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const content = readFileSync(filePath, "utf8");
|
|
76
|
+
return { ...hit, content };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the SKILL.md (or equivalent) inside a skill directory.
|
|
84
|
+
* Falls back to the first `*.md` if a canonical name is missing —
|
|
85
|
+
* matches the lenient discovery already used by `parsers/skill.ts`.
|
|
86
|
+
*/
|
|
87
|
+
function resolveSkillFile(dirPath: string): string | null {
|
|
88
|
+
try {
|
|
89
|
+
const stat = statSync(dirPath);
|
|
90
|
+
if (!stat.isDirectory()) {
|
|
91
|
+
// Already a file path — return as-is.
|
|
92
|
+
return dirPath;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const name of ["SKILL.md", "skill.md"]) {
|
|
99
|
+
const candidate = join(dirPath, name);
|
|
100
|
+
try {
|
|
101
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
102
|
+
} catch {
|
|
103
|
+
// missing — try next
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const fallback = readdirSync(dirPath).find((f) => f.toLowerCase().endsWith(".md"));
|
|
109
|
+
if (fallback) return join(dirPath, fallback);
|
|
110
|
+
} catch {
|
|
111
|
+
// unreadable
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function artifactToSummary(a: EnvironmentArtifact): SkillSummary {
|
|
118
|
+
return {
|
|
119
|
+
id: a.relPath,
|
|
120
|
+
name: a.name,
|
|
121
|
+
tool: a.tool,
|
|
122
|
+
scope: a.scope,
|
|
123
|
+
preview: a.preview,
|
|
124
|
+
sizeBytes: a.sizeBytes,
|
|
125
|
+
absPath: a.absPath,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
import { getLatestScan, getArtifacts } from "./data";
|
|
130
|
+
import { enrichSkills, type EnrichedSkill } from "./skill-enrichment";
|
|
131
|
+
|
|
132
|
+
export type { EnrichedSkill } from "./skill-enrichment";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Like `listSkills` but reads the latest scan directly from the DB so the
|
|
136
|
+
* result carries `linkedProfileId` (which isn't on the in-memory
|
|
137
|
+
* EnvironmentArtifact type), and folds in health + sync status via
|
|
138
|
+
* `enrichSkills`.
|
|
139
|
+
*
|
|
140
|
+
* Cache-only path — no filesystem I/O. Returns empty array if no scan has
|
|
141
|
+
* been persisted yet.
|
|
142
|
+
*/
|
|
143
|
+
export function listSkillsEnriched(
|
|
144
|
+
options: { nowMs?: number } = {}
|
|
145
|
+
): EnrichedSkill[] {
|
|
146
|
+
const latest = getLatestScan();
|
|
147
|
+
if (!latest) return [];
|
|
148
|
+
|
|
149
|
+
const rows = getArtifacts({ scanId: latest.id, category: "skill" });
|
|
150
|
+
|
|
151
|
+
const skills: SkillSummary[] = [];
|
|
152
|
+
const modifiedAtMsByPath: Record<string, number | null> = {};
|
|
153
|
+
const linkedProfilesByPath: Record<string, string | null> = {};
|
|
154
|
+
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
// Skip dot-prefixed system dirs (e.g. .system) — internal, not user-facing.
|
|
157
|
+
if (row.name.startsWith(".")) continue;
|
|
158
|
+
skills.push({
|
|
159
|
+
id: row.relPath,
|
|
160
|
+
name: row.name,
|
|
161
|
+
tool: row.tool,
|
|
162
|
+
scope: row.scope,
|
|
163
|
+
preview: row.preview ?? "",
|
|
164
|
+
sizeBytes: row.sizeBytes,
|
|
165
|
+
absPath: row.absPath,
|
|
166
|
+
});
|
|
167
|
+
modifiedAtMsByPath[row.absPath] = row.modifiedAt ?? null;
|
|
168
|
+
linkedProfilesByPath[row.absPath] = row.linkedProfileId ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return enrichSkills(skills, {
|
|
172
|
+
modifiedAtMsByPath,
|
|
173
|
+
linkedProfilesByPath,
|
|
174
|
+
nowMs: options.nowMs,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { parseSkillDir } from "../skill";
|
|
6
|
+
|
|
7
|
+
describe("parseSkillDir", () => {
|
|
8
|
+
let root: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
root = mkdtempSync(join(tmpdir(), "stagent-skill-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(root, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses a well-formed skill directory", () => {
|
|
19
|
+
const dir = join(root, "greeter");
|
|
20
|
+
mkdirSync(dir);
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(dir, "SKILL.md"),
|
|
23
|
+
"---\nname: Greeter\ndescription: Says hello\n---\nBody.\n"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const artifact = parseSkillDir(dir, "claude-code", "user", root);
|
|
27
|
+
|
|
28
|
+
expect(artifact).not.toBeNull();
|
|
29
|
+
expect(artifact!.name).toBe("greeter");
|
|
30
|
+
expect(artifact!.metadata.description).toBe("Says hello");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects a hidden dot-prefixed directory", () => {
|
|
34
|
+
const dir = join(root, ".system");
|
|
35
|
+
mkdirSync(dir);
|
|
36
|
+
writeFileSync(
|
|
37
|
+
join(dir, "SKILL.md"),
|
|
38
|
+
"---\nname: System\ndescription: Hidden\n---\n"
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const artifact = parseSkillDir(dir, "claude-code", "user", root);
|
|
42
|
+
|
|
43
|
+
expect(artifact).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects .DS_Store and similar hidden filesystem noise", () => {
|
|
47
|
+
const dir = join(root, ".DS_Store");
|
|
48
|
+
mkdirSync(dir);
|
|
49
|
+
|
|
50
|
+
const artifact = parseSkillDir(dir, "claude-code", "user", root);
|
|
51
|
+
|
|
52
|
+
expect(artifact).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Skills live in .claude/skills/<name>/ or ~/.codex/skills/<name>/.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readdirSync, readFileSync
|
|
6
|
+
import { readdirSync, readFileSync } from "fs";
|
|
7
7
|
import { join, basename } from "path";
|
|
8
8
|
import type { EnvironmentArtifact, ToolPersona, ArtifactScope } from "../types";
|
|
9
9
|
import { computeHash, safePreview, safeStat } from "./utils";
|
|
@@ -18,6 +18,10 @@ export function parseSkillDir(
|
|
|
18
18
|
if (!stat?.isDirectory()) return null;
|
|
19
19
|
|
|
20
20
|
const name = basename(dirPath);
|
|
21
|
+
// Skip hidden directories (e.g., .system, .DS_Store).
|
|
22
|
+
// These are never user-authored skills and would otherwise
|
|
23
|
+
// surface as spurious profiles under auto-promote.
|
|
24
|
+
if (name.startsWith(".")) return null;
|
|
21
25
|
let mainFile = "";
|
|
22
26
|
let content = "";
|
|
23
27
|
|
|
@@ -36,20 +40,37 @@ export function parseSkillDir(
|
|
|
36
40
|
return null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
// Extract description from YAML frontmatter if present
|
|
43
|
+
// Extract description from YAML frontmatter if present.
|
|
40
44
|
const metadata: Record<string, unknown> = {};
|
|
41
|
-
|
|
45
|
+
let bodyAfterFrontmatter = content;
|
|
46
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n*/);
|
|
42
47
|
if (fmMatch) {
|
|
43
48
|
for (const line of fmMatch[1].split("\n")) {
|
|
44
49
|
const colonIdx = line.indexOf(":");
|
|
45
50
|
if (colonIdx > 0) {
|
|
46
51
|
const key = line.slice(0, colonIdx).trim();
|
|
47
|
-
|
|
52
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
53
|
+
// Strip surrounding YAML quotes so the UI doesn't leak them.
|
|
54
|
+
if (
|
|
55
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
56
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
57
|
+
) {
|
|
58
|
+
value = value.slice(1, -1);
|
|
59
|
+
}
|
|
48
60
|
metadata[key] = value;
|
|
49
61
|
}
|
|
50
62
|
}
|
|
63
|
+
bodyAfterFrontmatter = content.slice(fmMatch[0].length);
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
// Prefer the frontmatter description as the human-facing preview so the
|
|
67
|
+
// UI does not leak raw YAML. Falls back to post-frontmatter body text.
|
|
68
|
+
const description =
|
|
69
|
+
typeof metadata.description === "string" && metadata.description.length > 0
|
|
70
|
+
? metadata.description
|
|
71
|
+
: null;
|
|
72
|
+
const preview = description ?? safePreview(bodyAfterFrontmatter);
|
|
73
|
+
|
|
53
74
|
return {
|
|
54
75
|
tool,
|
|
55
76
|
category: "skill",
|
|
@@ -58,7 +79,7 @@ export function parseSkillDir(
|
|
|
58
79
|
relPath: dirPath.replace(baseDir, "").replace(/^\//, ""),
|
|
59
80
|
absPath: dirPath,
|
|
60
81
|
contentHash: computeHash(content),
|
|
61
|
-
preview
|
|
82
|
+
preview,
|
|
62
83
|
metadata,
|
|
63
84
|
sizeBytes: Buffer.byteLength(content, "utf-8"),
|
|
64
85
|
modifiedAt: stat.mtimeMs,
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import { getArtifacts } from "./data";
|
|
6
6
|
import { evaluateRules, generateTier2Suggestions, type ProfileSuggestion } from "./profile-rules";
|
|
7
|
-
import { listProfiles,
|
|
7
|
+
import { listProfiles, createPromotedProfile } from "@/lib/agents/profiles/registry";
|
|
8
8
|
import type { ProfileConfig } from "@/lib/validators/profile";
|
|
9
|
+
import { getSettingSync } from "@/lib/settings/helpers";
|
|
10
|
+
import { SETTINGS_KEYS } from "@/lib/constants/settings";
|
|
11
|
+
import { linkArtifactsToProfiles } from "./profile-linker";
|
|
9
12
|
|
|
10
13
|
const MIN_CURATED_CONFIDENCE = 0.6;
|
|
11
14
|
|
|
@@ -123,9 +126,60 @@ export function createProfileFromSuggestion(
|
|
|
123
126
|
skillMd = skillMd.replace(suggestion.systemPrompt, overrides.systemPrompt);
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
createPromotedProfile(config, skillMd);
|
|
127
130
|
|
|
128
131
|
// Note: the created profile will have author "stagent-env" which,
|
|
129
132
|
// combined with the env- prefix on the ID, identifies it as environment-originated.
|
|
130
133
|
// The profile registry can infer origin from the author field.
|
|
131
134
|
}
|
|
135
|
+
|
|
136
|
+
export interface AutoPromoteResult {
|
|
137
|
+
created: string[];
|
|
138
|
+
skipped: string[];
|
|
139
|
+
errors: Array<{ ruleId: string; message: string }>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* If the AUTO_PROMOTE_SKILLS setting is enabled, auto-create profiles for
|
|
144
|
+
* every unlinked Tier 2 suggestion discovered in the given scan, then re-link
|
|
145
|
+
* the scan's artifacts so the newly-created profiles show as linked.
|
|
146
|
+
*
|
|
147
|
+
* Returns a summary of what happened. No-ops (returns empty result) when the
|
|
148
|
+
* setting is disabled.
|
|
149
|
+
*/
|
|
150
|
+
export function autoPromoteUnlinkedSkills(scanId: string): AutoPromoteResult {
|
|
151
|
+
const result: AutoPromoteResult = { created: [], skipped: [], errors: [] };
|
|
152
|
+
|
|
153
|
+
const flag = getSettingSync(SETTINGS_KEYS.AUTO_PROMOTE_SKILLS);
|
|
154
|
+
if (flag !== "true") return result;
|
|
155
|
+
|
|
156
|
+
const { discovered } = suggestProfilesTiered(scanId);
|
|
157
|
+
if (discovered.length === 0) return result;
|
|
158
|
+
|
|
159
|
+
for (const suggestion of discovered) {
|
|
160
|
+
try {
|
|
161
|
+
createProfileFromSuggestion(suggestion);
|
|
162
|
+
result.created.push(suggestion.ruleId);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Skip duplicates (registry throws when a profile with the same id
|
|
165
|
+
// already exists) — count them as skipped, not errors.
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
if (message.toLowerCase().includes("already exists")) {
|
|
168
|
+
result.skipped.push(suggestion.ruleId);
|
|
169
|
+
} else {
|
|
170
|
+
result.errors.push({ ruleId: suggestion.ruleId, message });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Re-link so freshly-created profiles are marked on their artifacts.
|
|
176
|
+
if (result.created.length > 0) {
|
|
177
|
+
try {
|
|
178
|
+
linkArtifactsToProfiles(scanId);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.warn("[auto-promote] Re-link after promotion failed:", err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type HealthScore = "healthy" | "stale" | "aging" | "broken" | "unknown";
|
|
2
|
+
|
|
3
|
+
export type SyncStatus =
|
|
4
|
+
| "synced"
|
|
5
|
+
| "claude-only"
|
|
6
|
+
| "codex-only"
|
|
7
|
+
| "shared";
|
|
8
|
+
|
|
9
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
10
|
+
const SIX_MONTHS_DAYS = 180;
|
|
11
|
+
const TWELVE_MONTHS_DAYS = 365;
|
|
12
|
+
|
|
13
|
+
export function computeHealthScore(
|
|
14
|
+
modifiedAtMs: number | null,
|
|
15
|
+
nowMs: number = Date.now()
|
|
16
|
+
): HealthScore {
|
|
17
|
+
if (modifiedAtMs == null) return "unknown";
|
|
18
|
+
const ageDays = (nowMs - modifiedAtMs) / MS_PER_DAY;
|
|
19
|
+
if (ageDays < SIX_MONTHS_DAYS) return "healthy";
|
|
20
|
+
if (ageDays < TWELVE_MONTHS_DAYS) return "stale";
|
|
21
|
+
return "aging";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute sync status from the set of tools that own the skill.
|
|
26
|
+
* - Both claude-code and codex present → "synced"
|
|
27
|
+
* - Only claude-code → "claude-only"
|
|
28
|
+
* - Only codex → "codex-only"
|
|
29
|
+
* - Only shared → "shared" (project-level file, no user peer expected)
|
|
30
|
+
* - claude-code + shared (or codex + shared) → treat as synced
|
|
31
|
+
*/
|
|
32
|
+
export function computeSyncStatus(tools: string[]): SyncStatus {
|
|
33
|
+
const set = new Set(tools);
|
|
34
|
+
const hasClaude = set.has("claude-code");
|
|
35
|
+
const hasCodex = set.has("codex");
|
|
36
|
+
const hasShared = set.has("shared");
|
|
37
|
+
if (hasClaude && hasCodex) return "synced";
|
|
38
|
+
if (hasClaude && hasShared) return "synced";
|
|
39
|
+
if (hasCodex && hasShared) return "synced";
|
|
40
|
+
if (hasClaude) return "claude-only";
|
|
41
|
+
if (hasCodex) return "codex-only";
|
|
42
|
+
return "shared";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
import type { SkillSummary } from "./list-skills";
|
|
46
|
+
|
|
47
|
+
export interface EnrichedSkill extends SkillSummary {
|
|
48
|
+
healthScore: HealthScore;
|
|
49
|
+
syncStatus: SyncStatus;
|
|
50
|
+
linkedProfileId: string | null;
|
|
51
|
+
/** All absPaths for the same skill name (for symlink/dup handling). */
|
|
52
|
+
absPaths: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EnrichmentContext {
|
|
56
|
+
modifiedAtMsByPath: Record<string, number | null>;
|
|
57
|
+
linkedProfilesByPath: Record<string, string | null>;
|
|
58
|
+
nowMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function enrichSkills(
|
|
62
|
+
skills: SkillSummary[],
|
|
63
|
+
ctx: EnrichmentContext
|
|
64
|
+
): EnrichedSkill[] {
|
|
65
|
+
const nowMs = ctx.nowMs ?? Date.now();
|
|
66
|
+
// Dedupe by absPath first (symlink loops).
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
const deduped: SkillSummary[] = [];
|
|
69
|
+
for (const s of skills) {
|
|
70
|
+
if (seen.has(s.absPath)) continue;
|
|
71
|
+
seen.add(s.absPath);
|
|
72
|
+
deduped.push(s);
|
|
73
|
+
}
|
|
74
|
+
// Group by name.
|
|
75
|
+
const byName = new Map<string, SkillSummary[]>();
|
|
76
|
+
for (const s of deduped) {
|
|
77
|
+
const list = byName.get(s.name) ?? [];
|
|
78
|
+
list.push(s);
|
|
79
|
+
byName.set(s.name, list);
|
|
80
|
+
}
|
|
81
|
+
const out: EnrichedSkill[] = [];
|
|
82
|
+
for (const [, group] of byName) {
|
|
83
|
+
const tools = group.map((g) => g.tool);
|
|
84
|
+
const syncStatus = computeSyncStatus(tools);
|
|
85
|
+
// Use the highest health (most recent modification) across the group.
|
|
86
|
+
const ages = group.map((g) => ctx.modifiedAtMsByPath[g.absPath] ?? null);
|
|
87
|
+
const newest = ages.reduce<number | null>(
|
|
88
|
+
(acc, v) => (v != null && (acc == null || v > acc) ? v : acc),
|
|
89
|
+
null
|
|
90
|
+
);
|
|
91
|
+
const healthScore = computeHealthScore(newest, nowMs);
|
|
92
|
+
const linkedProfileId =
|
|
93
|
+
group
|
|
94
|
+
.map((g) => ctx.linkedProfilesByPath[g.absPath] ?? null)
|
|
95
|
+
.find((v) => v != null) ?? null;
|
|
96
|
+
const primary = group[0];
|
|
97
|
+
out.push({
|
|
98
|
+
...primary,
|
|
99
|
+
healthScore,
|
|
100
|
+
syncStatus,
|
|
101
|
+
linkedProfileId,
|
|
102
|
+
absPaths: group.map((g) => g.absPath),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { EnrichedSkill } from "./skill-enrichment";
|
|
2
|
+
|
|
3
|
+
const STOPWORDS = new Set([
|
|
4
|
+
"the","and","for","with","that","this","from","have","your","will","not","but",
|
|
5
|
+
"you","are","was","can","any","all","has","his","her","how","who","why","what",
|
|
6
|
+
"when","where","use","using","used","its","into","new","one","two","get","got",
|
|
7
|
+
"please","help","like","need","want","make","made","just","also","some",
|
|
8
|
+
"more","most","very","than","then","them","they","their","out","off","put",
|
|
9
|
+
"let","say","said","see","saw","per","via","about","over","under","code",
|
|
10
|
+
"file","files",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const MIN_KEYWORD_LEN = 4;
|
|
14
|
+
const MIN_DISTINCT_HITS = 2;
|
|
15
|
+
|
|
16
|
+
interface Options {
|
|
17
|
+
activeSkillId?: string | null;
|
|
18
|
+
dismissedIds?: Set<string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenize(text: string): string[] {
|
|
22
|
+
return text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.split(/[^a-z0-9]+/)
|
|
25
|
+
.filter((t) => t.length >= MIN_KEYWORD_LEN && !STOPWORDS.has(t));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function computeRecommendation(
|
|
29
|
+
skills: EnrichedSkill[],
|
|
30
|
+
recentMessages: string[],
|
|
31
|
+
opts: Options = {}
|
|
32
|
+
): EnrichedSkill | null {
|
|
33
|
+
if (recentMessages.length === 0) return null;
|
|
34
|
+
const messageTokens = new Set(tokenize(recentMessages.join(" ")));
|
|
35
|
+
if (messageTokens.size === 0) return null;
|
|
36
|
+
|
|
37
|
+
const candidates: Array<{ skill: EnrichedSkill; hits: number }> = [];
|
|
38
|
+
|
|
39
|
+
for (const skill of skills) {
|
|
40
|
+
if (opts.activeSkillId && skill.id === opts.activeSkillId) continue;
|
|
41
|
+
if (opts.dismissedIds?.has(skill.id)) continue;
|
|
42
|
+
if (skill.healthScore !== "healthy" && skill.healthScore !== "stale") continue;
|
|
43
|
+
|
|
44
|
+
const skillTokens = new Set(tokenize(`${skill.name} ${skill.preview}`));
|
|
45
|
+
let hits = 0;
|
|
46
|
+
for (const t of skillTokens) {
|
|
47
|
+
if (messageTokens.has(t)) hits++;
|
|
48
|
+
}
|
|
49
|
+
if (hits >= MIN_DISTINCT_HITS) {
|
|
50
|
+
candidates.push({ skill, hits });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (candidates.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
// Rank by hits DESC, then health (healthy > stale), then name for determinism.
|
|
57
|
+
candidates.sort((a, b) => {
|
|
58
|
+
if (a.hits !== b.hits) return b.hits - a.hits;
|
|
59
|
+
if (a.skill.healthScore !== b.skill.healthScore) {
|
|
60
|
+
return a.skill.healthScore === "healthy" ? -1 : 1;
|
|
61
|
+
}
|
|
62
|
+
return a.skill.name.localeCompare(b.skill.name);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return candidates[0].skill;
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseFilterInput } from "../parse";
|
|
3
|
+
|
|
4
|
+
describe("parseFilterInput — quoted values", () => {
|
|
5
|
+
it("parses a double-quoted value with spaces", () => {
|
|
6
|
+
expect(parseFilterInput('#tag:"needs review"')).toEqual({
|
|
7
|
+
clauses: [{ key: "tag", value: "needs review" }],
|
|
8
|
+
rawQuery: "",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("preserves raw query surrounding a quoted clause", () => {
|
|
13
|
+
expect(parseFilterInput('auth #label:"in progress" redesign')).toEqual({
|
|
14
|
+
clauses: [{ key: "label", value: "in progress" }],
|
|
15
|
+
rawQuery: "auth redesign",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("allows `#` inside quoted values (previously a terminator)", () => {
|
|
20
|
+
expect(parseFilterInput('#note:"see #123"')).toEqual({
|
|
21
|
+
clauses: [{ key: "note", value: "see #123" }],
|
|
22
|
+
rawQuery: "",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("falls back to whitespace termination for unquoted values", () => {
|
|
27
|
+
expect(parseFilterInput("#status:blocked more text")).toEqual({
|
|
28
|
+
clauses: [{ key: "status", value: "blocked" }],
|
|
29
|
+
rawQuery: "more text",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats a lone opening quote as an unquoted value (malformed input survives)", () => {
|
|
34
|
+
// Fallback behavior: the regex's unquoted alternative matches `"unterminated` as the bare value
|
|
35
|
+
// since there's no closing quote. Acceptable degradation — no crash.
|
|
36
|
+
const result = parseFilterInput('#tag:"unterminated');
|
|
37
|
+
expect(result.clauses).toEqual([{ key: "tag", value: '"unterminated' }]);
|
|
38
|
+
expect(result.rawQuery).toBe("");
|
|
39
|
+
});
|
|
40
|
+
});
|