@sayknow-cli/coding-agent 0.5.25 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/types/config/settings-schema.d.ts +51 -5
  3. package/dist/types/config/task-model-specialties.d.ts +55 -0
  4. package/dist/types/decisions/keyword-learning.d.ts +61 -0
  5. package/dist/types/decisions/llm-backend.d.ts +13 -1
  6. package/dist/types/decisions/prompt-triage.d.ts +42 -0
  7. package/dist/types/decisions/skill-routing.d.ts +41 -6
  8. package/dist/types/decisions/task-routing.d.ts +96 -11
  9. package/dist/types/hooks/native-prompt-routing.d.ts +21 -0
  10. package/dist/types/hooks/native-skill-hook.d.ts +3 -0
  11. package/dist/types/hooks/skill-keywords.d.ts +9 -0
  12. package/dist/types/hooks/skill-state.d.ts +20 -3
  13. package/dist/types/hooks/ui-skill-keywords.d.ts +15 -0
  14. package/dist/types/i18n/messages/en.d.ts +15 -0
  15. package/dist/types/lsp/index.d.ts +1 -1
  16. package/dist/types/lsp/types.d.ts +1 -1
  17. package/dist/types/modes/components/model-selector.d.ts +11 -0
  18. package/dist/types/sdk/session.d.ts +3 -13
  19. package/dist/types/session/agent-session.d.ts +8 -0
  20. package/dist/types/session/auth-storage-discovery.d.ts +13 -0
  21. package/dist/types/task/index.d.ts +1 -1
  22. package/dist/types/task/receipt.d.ts +2 -0
  23. package/dist/types/task/types.d.ts +114 -18
  24. package/dist/types/tools/browser.d.ts +2 -2
  25. package/dist/types/tools/subagent.d.ts +2 -2
  26. package/package.json +7 -7
  27. package/scripts/eval-skill-routing.ts +37 -12
  28. package/src/config/settings-schema.ts +64 -12
  29. package/src/config/task-model-specialties.ts +131 -0
  30. package/src/decisions/index.ts +8 -2
  31. package/src/decisions/keyword-learning.ts +678 -0
  32. package/src/decisions/llm-backend.ts +213 -67
  33. package/src/decisions/prompt-triage.ts +163 -0
  34. package/src/decisions/skill-routing.ts +39 -56
  35. package/src/decisions/task-routing.ts +382 -66
  36. package/src/decisions/typesafe-backend.ts +3 -0
  37. package/src/hooks/native-prompt-routing.ts +190 -0
  38. package/src/hooks/native-skill-hook.ts +21 -12
  39. package/src/hooks/skill-keywords.ts +9 -0
  40. package/src/hooks/skill-state.ts +41 -10
  41. package/src/hooks/ui-skill-keywords.ts +67 -10
  42. package/src/i18n/messages/de.ts +16 -0
  43. package/src/i18n/messages/en.ts +16 -0
  44. package/src/i18n/messages/es.ts +16 -0
  45. package/src/i18n/messages/fr.ts +16 -0
  46. package/src/i18n/messages/ja.ts +16 -0
  47. package/src/i18n/messages/ko.ts +16 -0
  48. package/src/i18n/messages/zh.ts +16 -0
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/modes/components/model-selector.ts +275 -34
  52. package/src/modes/controllers/selector-controller.ts +50 -2
  53. package/src/modes/shared/agent-wire/command-dispatch.ts +1 -1
  54. package/src/prompts/tools/task.md +1 -0
  55. package/src/sdk/session.ts +5 -82
  56. package/src/session/agent-session.ts +137 -37
  57. package/src/session/auth-storage-discovery.ts +83 -0
  58. package/src/slash-commands/builtin-registry.ts +11 -9
  59. package/src/task/index.ts +98 -40
  60. package/src/task/receipt.ts +3 -0
  61. package/src/task/types.ts +44 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Prompt routing for the Codex host: the same three stages the session runs in
3
+ * `AgentSession#routeWorkflowSemantically`, rebuilt for a process that lives for
4
+ * one prompt.
5
+ *
6
+ * The session holds settings, credentials and a model registry for its whole
7
+ * life, so its model stage is a few lines. The hook is spawned fresh on every
8
+ * `UserPromptSubmit`, already costs ~260ms to start, and answers most prompts
9
+ * from the keyword tables alone — so everything the model stage needs is loaded
10
+ * lazily, and only on a prompt that actually reaches it. Concretely:
11
+ *
12
+ * - `decisions.enabled` / `decisions.keywordLearning` are read from the same raw
13
+ * `config.yml` files the hook already parses for skill discovery. Loading
14
+ * `Settings` for two booleans costs ~200ms of module graph on every prompt.
15
+ * - Learned keywords are one small JSON read, so they are always consulted: a
16
+ * pattern promoted in a CLI session must fire under Codex too.
17
+ * - Credentials, the registry and the decision service are opened only when the
18
+ * keyword tables did not fully answer, then closed before the hook exits.
19
+ *
20
+ * Every failure resolves to "no activation", which is what the hook did before
21
+ * this stage existed.
22
+ */
23
+ import { getAgentDir } from "@sayknow-cli/utils";
24
+ import { YAML } from "bun";
25
+ import { loadLearnedKeywordDefinitions, observeRouting } from "../decisions/keyword-learning";
26
+ import type { PromptTriage, PromptTriageRequest, PromptTriager } from "../decisions/prompt-triage";
27
+ import type { SkillKeywordDefinition } from "./skill-keywords";
28
+ import { recordSkillActivation, type SkillActiveState } from "./skill-state";
29
+ import { buildUiSkillDirectiveForSkill, detectUiSkillKeywords } from "./ui-skill-keywords";
30
+
31
+ export interface NativePromptRoutingInput {
32
+ cwd: string;
33
+ text: string;
34
+ sessionId?: string;
35
+ threadId?: string;
36
+ turnId?: string;
37
+ stateDir?: string;
38
+ /** `config.yml` files in precedence order, lowest first; resolved by the hook. */
39
+ configPaths: readonly string[];
40
+ agentDir?: string;
41
+ /** Injected in tests so routing is exercised without credentials or a network. */
42
+ triager?: PromptTriager;
43
+ }
44
+
45
+ export interface NativePromptRoutingResult {
46
+ skillState: SkillActiveState | null;
47
+ /** Directive for a UI skill the model picked because the pattern table missed. */
48
+ uiSkillContext: string | null;
49
+ }
50
+
51
+ interface DecisionSettings {
52
+ enabled: boolean;
53
+ keywordLearning: boolean;
54
+ }
55
+
56
+ /**
57
+ * The two decision switches, read the way the hook reads `skills.*`: raw YAML,
58
+ * later files override earlier ones, absent means the schema default (`true`).
59
+ */
60
+ async function readDecisionSettings(configPaths: readonly string[]): Promise<DecisionSettings> {
61
+ const settings: DecisionSettings = { enabled: true, keywordLearning: true };
62
+ for (const configPath of configPaths) {
63
+ let raw: unknown;
64
+ try {
65
+ raw = YAML.parse(await Bun.file(configPath).text());
66
+ } catch {
67
+ continue;
68
+ }
69
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
70
+ const decisions = (raw as Record<string, unknown>).decisions;
71
+ if (!decisions || typeof decisions !== "object" || Array.isArray(decisions)) continue;
72
+ const { enabled, keywordLearning } = decisions as Record<string, unknown>;
73
+ if (typeof enabled === "boolean") settings.enabled = enabled;
74
+ if (typeof keywordLearning === "boolean") settings.keywordLearning = keywordLearning;
75
+ }
76
+ return settings;
77
+ }
78
+
79
+ /**
80
+ * Open credentials and the registry, build the triager, and hand back the
81
+ * teardown. Imported lazily: this graph is what the fast path must never pay for.
82
+ */
83
+ async function openTriager(input: NativePromptRoutingInput): Promise<{ triager: PromptTriager; close: () => void }> {
84
+ const [{ Settings }, { ModelRegistry }, { discoverAuthStorage }, { createDecisionService }, { createPromptTriage }] =
85
+ await Promise.all([
86
+ import("../config/settings"),
87
+ import("../config/model-registry"),
88
+ import("../session/auth-storage-discovery"),
89
+ import("../decisions"),
90
+ import("../decisions/prompt-triage"),
91
+ ]);
92
+ const agentDir = input.agentDir ?? getAgentDir();
93
+ const settings = await Settings.loadForScope({ cwd: input.cwd, agentDir });
94
+ const authStorage = await discoverAuthStorage(agentDir);
95
+ try {
96
+ const registry = new ModelRegistry(authStorage);
97
+ registry.applyConfiguredModelBindings(settings);
98
+ // Offline: built-in providers are static, and a local runtime's model list
99
+ // comes from the 24h discovery cache the CLI maintains. A network refresh here
100
+ // would put an unbounded fetch in front of every Codex prompt.
101
+ await registry.refresh("offline");
102
+ const service = createDecisionService({
103
+ registry,
104
+ settings,
105
+ sessionId: input.sessionId,
106
+ // This hook only runs under the Codex host, so the provider the user is
107
+ // already talking to is Codex; its small model ranks first when the user
108
+ // has that credential, and the hint is inert when they do not.
109
+ preferredProvider: "openai-codex",
110
+ enabled: true,
111
+ });
112
+ return { triager: createPromptTriage(service), close: () => authStorage.close() };
113
+ } catch (error) {
114
+ authStorage.close();
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export async function routeNativePrompt(input: NativePromptRoutingInput): Promise<NativePromptRoutingResult> {
120
+ const decisions = await readDecisionSettings(input.configPaths);
121
+ const learned = await loadLearned(decisions.keywordLearning);
122
+ const uiMatched = detectUiSkillKeywords(input.text).length > 0;
123
+
124
+ let opened: { triager: PromptTriager; close: () => void } | undefined;
125
+ const triage = async (request: Omit<PromptTriageRequest, "text">): Promise<PromptTriage | null> => {
126
+ if (input.triager) return await input.triager({ text: input.text, ...request });
127
+ opened ??= await openTriager(input);
128
+ return await opened.triager({ text: input.text, ...request });
129
+ };
130
+
131
+ // Written from inside the semantic callback; a plain `let` would be narrowed
132
+ // to its initialiser by control-flow analysis and read as never-assigned here.
133
+ const seen: { semanticConsulted: boolean; result: PromptTriage | null } = { semanticConsulted: false, result: null };
134
+ try {
135
+ const skillState = await recordSkillActivation({
136
+ cwd: input.cwd,
137
+ text: input.text,
138
+ sessionId: input.sessionId,
139
+ threadId: input.threadId,
140
+ turnId: input.turnId,
141
+ stateDir: input.stateDir,
142
+ learned,
143
+ // Consulted only when no keyword matched — the same turn the session
144
+ // would spend a model call on, asking both questions at once.
145
+ resolveSkillSemantically: decisions.enabled
146
+ ? async () => {
147
+ seen.semanticConsulted = true;
148
+ const result = await triage({ skipUiSkill: uiMatched });
149
+ seen.result = result;
150
+ // A null result is "no information" and teaches nothing; a result
151
+ // with a null workflow is the model saying "none", which is a
152
+ // negative example the learner needs.
153
+ if (decisions.keywordLearning && result) {
154
+ await observeRouting({
155
+ text: input.text,
156
+ skill: result.workflow,
157
+ confidence: result.workflowConfidence,
158
+ calibrated: result.calibrated,
159
+ });
160
+ }
161
+ return result?.workflow ?? null;
162
+ }
163
+ : undefined,
164
+ });
165
+ // A keyword answered the workflow question but the UI pattern table missed:
166
+ // the session still asks the UI question alone, and so does the hook.
167
+ if (decisions.enabled && !seen.semanticConsulted && !uiMatched) {
168
+ try {
169
+ seen.result = await triage({ skipWorkflow: true });
170
+ } catch {
171
+ seen.result = null;
172
+ }
173
+ }
174
+ return {
175
+ skillState,
176
+ uiSkillContext: seen.result?.uiSkill ? buildUiSkillDirectiveForSkill(seen.result.uiSkill) : null,
177
+ };
178
+ } finally {
179
+ opened?.close();
180
+ }
181
+ }
182
+
183
+ async function loadLearned(enabled: boolean): Promise<SkillKeywordDefinition[]> {
184
+ if (!enabled) return [];
185
+ try {
186
+ return await loadLearnedKeywordDefinitions();
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
@@ -5,7 +5,9 @@ import { getAgentDir, getConfigDirName } from "@sayknow-cli/utils";
5
5
  import { YAML } from "bun";
6
6
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
7
7
  import { DEFAULT_DISABLED_EXTENSIONS, DEFAULT_SKILL_DISCOVERY_SETTINGS } from "../config/skill-settings-defaults";
8
+ import type { PromptTriager } from "../decisions/prompt-triage";
8
9
  import { sessionLogsDir } from "../skc-runtime/session-layout";
10
+ import { routeNativePrompt } from "./native-prompt-routing";
9
11
  import {
10
12
  buildActiveUltragoalPromptContext,
11
13
  buildSkillActivationAdditionalContext,
@@ -13,7 +15,6 @@ import {
13
15
  buildStateRecoveryDiagnosticsContext,
14
16
  collectUserPromptStateRecoveryDiagnostics,
15
17
  type EffectiveSkillConfigInput,
16
- recordSkillActivation,
17
18
  } from "./skill-state";
18
19
  import { buildExternalUiSkillContext, buildUiSkillActivationContext } from "./ui-skill-keywords";
19
20
 
@@ -32,6 +33,8 @@ interface SkcNativeHookDispatchOptions {
32
33
  stateDir?: string;
33
34
  effectiveSkillConfig?: EffectiveSkillConfigInput;
34
35
  configPaths?: string[];
36
+ /** Injected in tests so the model stage runs without credentials or a network. */
37
+ triager?: PromptTriager;
35
38
  }
36
39
 
37
40
  interface ConfigCacheEntry {
@@ -337,19 +340,25 @@ async function dispatchSkcNativeSkillHookInner(
337
340
  }
338
341
 
339
342
  let skillState = null;
343
+ let semanticUiContext: string | null = null;
340
344
  try {
341
- skillState = prompt
342
- ? await recordSkillActivation({
343
- cwd,
344
- text: prompt,
345
- sessionId: readSessionId(payload),
346
- threadId: readThreadId(payload),
347
- turnId: readTurnId(payload),
348
- stateDir: options.stateDir,
349
- })
350
- : null;
345
+ if (prompt) {
346
+ const routed = await routeNativePrompt({
347
+ cwd,
348
+ text: prompt,
349
+ sessionId: readSessionId(payload),
350
+ threadId: readThreadId(payload),
351
+ turnId: readTurnId(payload),
352
+ stateDir: options.stateDir,
353
+ configPaths: resolveConfigPaths(cwd, options.configPaths),
354
+ triager: options.triager,
355
+ });
356
+ skillState = routed.skillState;
357
+ semanticUiContext = routed.uiSkillContext;
358
+ }
351
359
  } catch {
352
360
  skillState = null;
361
+ semanticUiContext = null;
353
362
  }
354
363
 
355
364
  let effectiveSkillConfig: EffectiveSkillConfigInput | undefined;
@@ -395,7 +404,7 @@ async function dispatchSkcNativeSkillHookInner(
395
404
  }
396
405
  const additionalContext = [
397
406
  skillState ? buildSkillActivationAdditionalContext(skillState, effectiveSkillConfig) : activeUltragoalContext,
398
- uiSkillContext,
407
+ uiSkillContext ?? semanticUiContext,
399
408
  externalUiContext,
400
409
  recoveryContext,
401
410
  classifyQuestionOnlyPrompt(prompt),
@@ -5,6 +5,15 @@ export interface SkillKeywordDefinition {
5
5
  skill: SkcWorkflowSkill;
6
6
  priority: number;
7
7
  guidance: string;
8
+ /**
9
+ * Pre-compiled matcher, replacing the literal-substring compilation of
10
+ * `keyword`. Only learned entries set it: they are two stems with a bounded
11
+ * gap, which a literal string cannot express. `keyword` stays human-readable
12
+ * for logs and for the guidance line.
13
+ */
14
+ pattern?: RegExp;
15
+ /** Mined from routing answers rather than written by hand. */
16
+ learned?: boolean;
8
17
  }
9
18
 
10
19
  export const SKC_WORKFLOW_SKILLS = CANONICAL_SKC_WORKFLOW_SKILLS;
@@ -32,6 +32,7 @@ import {
32
32
  isSkcWorkflowSkill,
33
33
  SKC_SKILL_KEYWORD_DEFINITIONS,
34
34
  type SkcWorkflowSkill,
35
+ type SkillKeywordDefinition,
35
36
  } from "./skill-keywords";
36
37
 
37
38
  export const SKC_STATE_DIR = ".skc";
@@ -95,6 +96,8 @@ export interface SkillKeywordMatch {
95
96
  keyword: string;
96
97
  skill: SkcWorkflowSkill;
97
98
  priority: number;
99
+ /** Matched a pattern mined from routing answers, not a hand-written keyword. */
100
+ learned?: boolean;
98
101
  }
99
102
 
100
103
  export type { SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
@@ -121,6 +124,12 @@ export interface RecordSkillActivationInput {
121
124
  turnId?: string;
122
125
  nowIso?: string;
123
126
  stateDir?: string;
127
+ /**
128
+ * Patterns promoted by the keyword learner, appended after the hand-written
129
+ * table. The caller loads them because it knows whether learning is on; see
130
+ * `detectSkillKeywords`.
131
+ */
132
+ learned?: readonly SkillKeywordDefinition[];
124
133
  /**
125
134
  * Semantic fallback, consulted only when no keyword matched. Supplying it turns the
126
135
  * literal keyword table into a two-stage router; omitting it keeps the historical
@@ -161,10 +170,11 @@ function keywordToPattern(keyword: string): RegExp {
161
170
  return new RegExp(`${prefix}${escaped}${suffix}`, "i");
162
171
  }
163
172
 
164
- const KEYWORD_PATTERNS = SKC_SKILL_KEYWORD_DEFINITIONS.map(definition => ({
165
- ...definition,
166
- pattern: keywordToPattern(definition.keyword),
167
- }));
173
+ function compileDefinition(definition: SkillKeywordDefinition): SkillKeywordDefinition & { pattern: RegExp } {
174
+ return { ...definition, pattern: definition.pattern ?? keywordToPattern(definition.keyword) };
175
+ }
176
+
177
+ const KEYWORD_PATTERNS = SKC_SKILL_KEYWORD_DEFINITIONS.map(compileDefinition);
168
178
 
169
179
  function parseExplicitSkillInvocations(text: string): {
170
180
  matches: SkillKeywordMatch[];
@@ -192,16 +202,34 @@ function parseExplicitSkillInvocations(text: string): {
192
202
  return { matches, sawExplicitLikeInvocation };
193
203
  }
194
204
 
195
- export function detectSkillKeywords(text: string): SkillKeywordMatch[] {
205
+ /**
206
+ * Match a prompt against the keyword table.
207
+ *
208
+ * `learned` carries patterns mined from semantic routing answers (see
209
+ * `decisions/keyword-learning.ts`). It is a parameter rather than a module-level
210
+ * load because this file is imported by the hook process, where a synchronous
211
+ * disk read on every prompt is not acceptable and the caller already knows
212
+ * whether learning is enabled.
213
+ */
214
+ export function detectSkillKeywords(
215
+ text: string,
216
+ learned: readonly SkillKeywordDefinition[] = [],
217
+ ): SkillKeywordMatch[] {
196
218
  const explicit = parseExplicitSkillInvocations(text);
197
219
  if (explicit.matches.length > 0) return explicit.matches;
198
220
  if (explicit.sawExplicitLikeInvocation) return [];
199
221
 
200
222
  const implicit: SkillKeywordMatch[] = [];
201
- for (const definition of KEYWORD_PATTERNS) {
223
+ const definitions = learned.length > 0 ? [...KEYWORD_PATTERNS, ...learned.map(compileDefinition)] : KEYWORD_PATTERNS;
224
+ for (const definition of definitions) {
202
225
  const match = text.match(definition.pattern);
203
226
  if (!match) continue;
204
- implicit.push({ keyword: match[0], skill: definition.skill, priority: definition.priority });
227
+ implicit.push({
228
+ keyword: match[0],
229
+ skill: definition.skill,
230
+ priority: definition.priority,
231
+ ...(definition.learned ? { learned: true } : {}),
232
+ });
205
233
  }
206
234
 
207
235
  const merged: SkillKeywordMatch[] = [];
@@ -212,8 +240,11 @@ export function detectSkillKeywords(text: string): SkillKeywordMatch[] {
212
240
  return merged;
213
241
  }
214
242
 
215
- export function detectPrimarySkillKeyword(text: string): SkillKeywordMatch | null {
216
- return detectSkillKeywords(text)[0] ?? null;
243
+ export function detectPrimarySkillKeyword(
244
+ text: string,
245
+ learned: readonly SkillKeywordDefinition[] = [],
246
+ ): SkillKeywordMatch | null {
247
+ return detectSkillKeywords(text, learned)[0] ?? null;
217
248
  }
218
249
 
219
250
  export function resolveSkcStateDir(cwd: string, stateDir?: string): string {
@@ -476,7 +507,7 @@ async function seedSkillActivationState(
476
507
  // Fallback for native-hook prompts when SkillPromptDetails.subskillActivation is absent;
477
508
  // real /skill dispatch paths resolve sub-skill activation before prompt construction.
478
509
  export async function recordSkillActivation(input: RecordSkillActivationInput): Promise<SkillActiveState | null> {
479
- const match = detectPrimarySkillKeyword(input.text);
510
+ const match = detectPrimarySkillKeyword(input.text, input.learned);
480
511
  if (match) return await seedSkillActivationState(match.skill, match.keyword, "skc-skill-state-hook", input);
481
512
  if (!input.resolveSkillSemantically) return null;
482
513
  // The semantic stage is advisory: any failure leaves routing to the system prompt,
@@ -160,9 +160,29 @@ export function detectUiSkillKeywords(text: string): UiSkillKeywordMatch[] {
160
160
  }
161
161
  // Keep the lead skill plus the craft baseline; more than that is noise.
162
162
  const lead = deduped[0]!;
163
- if (lead.skill === UI_SKILL_BASE || SELF_SUFFICIENT_UI_SKILLS.has(lead.skill)) return [lead];
164
- const base = deduped.find(item => item.skill === UI_SKILL_BASE);
165
- return base ? [lead, base] : [lead];
163
+ return pairWithBaseline(lead.skill);
164
+ }
165
+
166
+ /**
167
+ * A specialized skill plus the craft baseline, which is the pairing the skills
168
+ * themselves assume. Skills that carry their own complete craft bar stand alone.
169
+ */
170
+ function pairWithBaseline(skill: BundledSkcUiSkillName): UiSkillKeywordMatch[] {
171
+ const definition = UI_SKILL_KEYWORD_DEFINITIONS.find(item => item.skill === skill);
172
+ const lead: UiSkillKeywordMatch = { skill, keyword: skill, priority: definition?.priority ?? 0 };
173
+ if (skill === UI_SKILL_BASE || SELF_SUFFICIENT_UI_SKILLS.has(skill)) return [lead];
174
+ const base = UI_SKILL_KEYWORD_DEFINITIONS.find(item => item.skill === UI_SKILL_BASE);
175
+ return [lead, { skill: UI_SKILL_BASE, keyword: UI_SKILL_BASE, priority: base?.priority ?? 0 }];
176
+ }
177
+
178
+ function renderUiSkillDirective(skills: readonly BundledSkcUiSkillName[], evidence: string): string {
179
+ const names = skills.map(skill => `\`${skill}\``).join(" then ");
180
+ return [
181
+ `SKC detected frontend UI/UX work in this prompt (${evidence}).`,
182
+ `Before writing or reviewing that surface, load ${names} with the \`skill\` tool.`,
183
+ "These skills are bundled with SKC: never tell the user to install them, and never skip them because the task looks small.",
184
+ "They are not workflow skills and have no `skc state` mode state.",
185
+ ].join(" ");
166
186
  }
167
187
 
168
188
  /**
@@ -173,15 +193,52 @@ export function detectUiSkillKeywords(text: string): UiSkillKeywordMatch[] {
173
193
  export function buildUiSkillActivationContext(text: string): string | null {
174
194
  const matches = detectUiSkillKeywords(text);
175
195
  if (matches.length === 0) return null;
176
- const names = matches.map(match => `\`${match.skill}\``).join(" then ");
177
- return [
178
- `SKC detected frontend UI/UX work in this prompt (matched "${matches[0]!.keyword}").`,
179
- `Before writing or reviewing that surface, load ${names} with the \`skill\` tool.`,
180
- "These skills are bundled with SKC: never tell the user to install them, and never skip them because the task looks small.",
181
- "They are not workflow skills and have no `skc state` mode state.",
182
- ].join(" ");
196
+ return renderUiSkillDirective(
197
+ matches.map(match => match.skill),
198
+ `matched "${matches[0]!.keyword}"`,
199
+ );
183
200
  }
184
201
 
202
+ /**
203
+ * Same directive, for a skill chosen by the semantic stage rather than by a
204
+ * pattern. The regex table caught 5 of 8 real frontend prompts; this is the path
205
+ * for the other three, and it names its source so a wrong pick is traceable to
206
+ * the model rather than to a pattern nobody can find.
207
+ */
208
+ export function buildUiSkillDirectiveForSkill(skill: BundledSkcUiSkillName): string {
209
+ return renderUiSkillDirective(
210
+ pairWithBaseline(skill).map(match => match.skill),
211
+ "semantic match",
212
+ );
213
+ }
214
+
215
+ /**
216
+ * What each bundled skill is *for*, in the words a user would recognise.
217
+ *
218
+ * This is the whole contract with the routing model — the skill ids alone carry
219
+ * almost no signal, and `appllama-app-design-skill` carries actively misleading
220
+ * signal. Kept next to the patterns so the two cannot drift apart.
221
+ */
222
+ export const BUNDLED_UI_SKILL_MEANINGS: Record<BundledSkcUiSkillName, string> = {
223
+ "emil-design-eng":
224
+ "General web UI/UX craft: building or reviewing components, layout, spacing, typography, design systems, CSS. The default for frontend work with no more specific fit.",
225
+ animate:
226
+ "Adding new motion to a web UI: animations, transitions, easing, keyframes, hover effects, micro-interactions.",
227
+ "review-animations": "Reviewing motion that already exists and judging whether it is good.",
228
+ "improve-animations": "Auditing and fixing motion that already exists and is known to be wrong or cheap-looking.",
229
+ "find-animation-opportunities":
230
+ "Asking where motion could be added to a surface that currently has none, without naming a specific effect.",
231
+ "pick-ui-library": "Choosing between UI libraries, component kits, or frontend packages.",
232
+ prototype: "Exploring several visual variants or mockups of the same screen before committing to one.",
233
+ "mobile-native":
234
+ "Making a web page on a phone feel native: touch targets, safe areas, viewport and scroll behaviour, gestures.",
235
+ "animation-vocabulary": "Naming a motion effect the user described but could not name.",
236
+ "apple-design": "Apple-style motion, materials, and Human Interface Guidelines feel.",
237
+ "ask-sonner": "Toast notifications, specifically the Sonner library.",
238
+ "appllama-app-design-skill": "Native app screens built with Expo or React Native — not a website viewed on a phone.",
239
+ "react-bits": "Pre-built animated React components: animated text, animated backgrounds, cursor and scroll effects.",
240
+ };
241
+
185
242
  /**
186
243
  * Frontend skills SKC routes to but deliberately does NOT vendor.
187
244
  *
@@ -75,6 +75,22 @@ export const de: Partial<Record<MsgKey, string>> = {
75
75
  "modelSelector.noMatching": "Keine passenden Modelle.",
76
76
  "modelSelector.modelName": "Modellname: {value}",
77
77
  "modelSelector.actionFor": "Aktion für: {id}",
78
+ "modelSelector.setAsTarget": "Als {tag} ({name}) festlegen",
79
+ "modelSelector.setForAllRoleAgents": "Fur alle Rollen-Agenten setzen",
80
+ "modelSelector.setForAllTargets": "Fur alle Ziele setzen",
81
+ "modelSelector.hasDetailedUses": "(detaillierte Aufgaben)",
82
+ "modelSelector.detailedUseFor": "Detaillierte Aufgabe fur {target}: {id}",
83
+ "modelSelector.generalRole": "Allgemein (ganze Rolle)",
84
+ "modelSelector.resetSpecialties": "Detaillierte Aufgaben zurucksetzen",
85
+ "modelSelector.specialty.backendArchitecture": "Backend-Architektur",
86
+ "modelSelector.specialty.frontendDesign": "Frontend-Design",
87
+ "modelSelector.specialty.implementation": "Implementierung",
88
+ "modelSelector.specialty.testing": "Tests",
89
+ "modelSelector.specialty.review": "Review",
90
+ "modelSelector.specialtySaved": "{specialty} verwendet {value}.",
91
+ "modelSelector.specialtySavedRoutingOff":
92
+ "{specialty} verwendet {value}, sobald eine Aufgabe diese Arbeit deklariert. Automatische Erkennung aus dem Auftragstext bleibt aus, bis task.modelRouting.enabled aktiv ist.",
93
+ "modelSelector.specialtyCleared": "Detaillierte Aufgaben fur {target} zuruckgesetzt.",
78
94
  "modelSelector.reasoningFor": "Reasoning für {target}: {id}",
79
95
  "modelSelector.temporaryModel": "temporäres Modell",
80
96
  };
@@ -95,6 +95,22 @@ export const en = {
95
95
  "modelSelector.noMatching": "No matching models.",
96
96
  "modelSelector.modelName": "Model Name: {value}",
97
97
  "modelSelector.actionFor": "Action for: {id}",
98
+ "modelSelector.setAsTarget": "Set as {tag} ({name})",
99
+ "modelSelector.setForAllRoleAgents": "Set for all role agents",
100
+ "modelSelector.setForAllTargets": "Set for all targets",
101
+ "modelSelector.hasDetailedUses": "(detailed uses)",
102
+ "modelSelector.detailedUseFor": "Detailed use for {target}: {id}",
103
+ "modelSelector.generalRole": "General (whole role)",
104
+ "modelSelector.resetSpecialties": "Clear detailed-use overrides",
105
+ "modelSelector.specialty.backendArchitecture": "Backend architecture",
106
+ "modelSelector.specialty.frontendDesign": "Frontend design",
107
+ "modelSelector.specialty.implementation": "Implementation",
108
+ "modelSelector.specialty.testing": "Testing",
109
+ "modelSelector.specialty.review": "Review",
110
+ "modelSelector.specialtySaved": "{specialty} will use {value}.",
111
+ "modelSelector.specialtySavedRoutingOff":
112
+ "{specialty} will use {value} whenever a task declares that work. Auto-detection from assignment text stays off until you enable task.modelRouting.enabled.",
113
+ "modelSelector.specialtyCleared": "Cleared detailed-use overrides for {target}.",
98
114
  "modelSelector.reasoningFor": "Reasoning for {target}: {id}",
99
115
  "modelSelector.temporaryModel": "temporary model",
100
116
  } as const;
@@ -75,6 +75,22 @@ export const es: Partial<Record<MsgKey, string>> = {
75
75
  "modelSelector.noMatching": "No hay modelos coincidentes.",
76
76
  "modelSelector.modelName": "Nombre del modelo: {value}",
77
77
  "modelSelector.actionFor": "Acción para: {id}",
78
+ "modelSelector.setAsTarget": "Asignar como {tag} ({name})",
79
+ "modelSelector.setForAllRoleAgents": "Asignar a todos los agentes de rol",
80
+ "modelSelector.setForAllTargets": "Asignar a todos los destinos",
81
+ "modelSelector.hasDetailedUses": "(usos detallados)",
82
+ "modelSelector.detailedUseFor": "Uso detallado de {target}: {id}",
83
+ "modelSelector.generalRole": "General (todo el rol)",
84
+ "modelSelector.resetSpecialties": "Borrar los usos detallados",
85
+ "modelSelector.specialty.backendArchitecture": "Arquitectura de backend",
86
+ "modelSelector.specialty.frontendDesign": "Diseño de frontend",
87
+ "modelSelector.specialty.implementation": "Implementación",
88
+ "modelSelector.specialty.testing": "Pruebas",
89
+ "modelSelector.specialty.review": "Revisión",
90
+ "modelSelector.specialtySaved": "{specialty} usará {value}.",
91
+ "modelSelector.specialtySavedRoutingOff":
92
+ "{specialty} usará {value} cuando una tarea declare ese trabajo. La detección automática desde el texto de la tarea sigue desactivada hasta que actives task.modelRouting.enabled.",
93
+ "modelSelector.specialtyCleared": "Se borraron los usos detallados de {target}.",
78
94
  "modelSelector.reasoningFor": "Razonamiento para {target}: {id}",
79
95
  "modelSelector.temporaryModel": "modelo temporal",
80
96
  };
@@ -75,6 +75,22 @@ export const fr: Partial<Record<MsgKey, string>> = {
75
75
  "modelSelector.noMatching": "Aucun modèle correspondant.",
76
76
  "modelSelector.modelName": "Nom du modèle : {value}",
77
77
  "modelSelector.actionFor": "Action pour : {id}",
78
+ "modelSelector.setAsTarget": "Définir comme {tag} ({name})",
79
+ "modelSelector.setForAllRoleAgents": "Definir pour tous les agents de role",
80
+ "modelSelector.setForAllTargets": "Definir pour toutes les cibles",
81
+ "modelSelector.hasDetailedUses": "(usages detailles)",
82
+ "modelSelector.detailedUseFor": "Usage detaille pour {target} : {id}",
83
+ "modelSelector.generalRole": "General (tout le role)",
84
+ "modelSelector.resetSpecialties": "Effacer les usages detailles",
85
+ "modelSelector.specialty.backendArchitecture": "Architecture backend",
86
+ "modelSelector.specialty.frontendDesign": "Design frontend",
87
+ "modelSelector.specialty.implementation": "Implementation",
88
+ "modelSelector.specialty.testing": "Tests",
89
+ "modelSelector.specialty.review": "Revue",
90
+ "modelSelector.specialtySaved": "{specialty} utilisera {value}.",
91
+ "modelSelector.specialtySavedRoutingOff":
92
+ "{specialty} utilisera {value} des qu'une tache declare ce travail. La detection automatique depuis le texte reste inactive sans task.modelRouting.enabled.",
93
+ "modelSelector.specialtyCleared": "Usages detailles effaces pour {target}.",
78
94
  "modelSelector.reasoningFor": "Raisonnement pour {target} : {id}",
79
95
  "modelSelector.temporaryModel": "modèle temporaire",
80
96
  };
@@ -79,6 +79,22 @@ export const ja: Partial<Record<MsgKey, string>> = {
79
79
  "modelSelector.noMatching": "一致するモデルがありません。",
80
80
  "modelSelector.modelName": "モデル名: {value}",
81
81
  "modelSelector.actionFor": "操作対象: {id}",
82
+ "modelSelector.setAsTarget": "{tag} に設定",
83
+ "modelSelector.setForAllRoleAgents": "すべてのロールエージェントに設定",
84
+ "modelSelector.setForAllTargets": "すべての対象に設定",
85
+ "modelSelector.hasDetailedUses": "(詳細用途あり)",
86
+ "modelSelector.detailedUseFor": "{target} の詳細用途: {id}",
87
+ "modelSelector.generalRole": "一般(ロール全体)",
88
+ "modelSelector.resetSpecialties": "詳細用途の設定を解除",
89
+ "modelSelector.specialty.backendArchitecture": "バックエンド設計",
90
+ "modelSelector.specialty.frontendDesign": "フロントエンドデザイン",
91
+ "modelSelector.specialty.implementation": "実装",
92
+ "modelSelector.specialty.testing": "テスト",
93
+ "modelSelector.specialty.review": "レビュー",
94
+ "modelSelector.specialtySaved": "{specialty} には {value} を使用します。",
95
+ "modelSelector.specialtySavedRoutingOff":
96
+ "{specialty} として宣言されたタスクには {value} を使用します。指示文からの自動判定は task.modelRouting.enabled を有効にするまで無効です。",
97
+ "modelSelector.specialtyCleared": "{target} の詳細用途設定を解除しました。",
82
98
  "modelSelector.reasoningFor": "{target} の推論レベル: {id}",
83
99
  "modelSelector.temporaryModel": "一時モデル",
84
100
  };
@@ -79,6 +79,22 @@ export const ko: Partial<Record<MsgKey, string>> = {
79
79
  "modelSelector.noMatching": "일치하는 모델이 없습니다.",
80
80
  "modelSelector.modelName": "모델 이름: {value}",
81
81
  "modelSelector.actionFor": "작업 대상: {id}",
82
+ "modelSelector.setAsTarget": "{tag}에 설정",
83
+ "modelSelector.setForAllRoleAgents": "모든 역할 에이전트에 설정",
84
+ "modelSelector.setForAllTargets": "모든 대상에 설정",
85
+ "modelSelector.hasDetailedUses": "(세부 업무 있음)",
86
+ "modelSelector.detailedUseFor": "{target} 세부 업무: {id}",
87
+ "modelSelector.generalRole": "일반 (역할 전체)",
88
+ "modelSelector.resetSpecialties": "세부 업무 설정 해제",
89
+ "modelSelector.specialty.backendArchitecture": "백엔드 아키텍처",
90
+ "modelSelector.specialty.frontendDesign": "프런트엔드 디자인",
91
+ "modelSelector.specialty.implementation": "구현",
92
+ "modelSelector.specialty.testing": "테스트",
93
+ "modelSelector.specialty.review": "리뷰",
94
+ "modelSelector.specialtySaved": "{specialty} 작업에 {value}을(를) 사용합니다.",
95
+ "modelSelector.specialtySavedRoutingOff":
96
+ "{specialty} 작업으로 선언된 태스크는 {value}을(를) 사용합니다. 지시문에서 자동 감지하려면 task.modelRouting.enabled를 켜야 합니다.",
97
+ "modelSelector.specialtyCleared": "{target}의 세부 업무 설정을 해제했습니다.",
82
98
  "modelSelector.reasoningFor": "{target} 추론 수준: {id}",
83
99
  "modelSelector.temporaryModel": "임시 모델",
84
100
  };
@@ -79,6 +79,22 @@ export const zh: Partial<Record<MsgKey, string>> = {
79
79
  "modelSelector.noMatching": "没有匹配的模型。",
80
80
  "modelSelector.modelName": "模型名称:{value}",
81
81
  "modelSelector.actionFor": "操作对象:{id}",
82
+ "modelSelector.setAsTarget": "设为 {tag}",
83
+ "modelSelector.setForAllRoleAgents": "设为所有角色代理",
84
+ "modelSelector.setForAllTargets": "设为所有对象",
85
+ "modelSelector.hasDetailedUses": "(含细分用途)",
86
+ "modelSelector.detailedUseFor": "{target} 的细分用途:{id}",
87
+ "modelSelector.generalRole": "通用(整个角色)",
88
+ "modelSelector.resetSpecialties": "清除细分用途设置",
89
+ "modelSelector.specialty.backendArchitecture": "后端架构",
90
+ "modelSelector.specialty.frontendDesign": "前端设计",
91
+ "modelSelector.specialty.implementation": "实现",
92
+ "modelSelector.specialty.testing": "测试",
93
+ "modelSelector.specialty.review": "评审",
94
+ "modelSelector.specialtySaved": "{specialty} 将使用 {value}。",
95
+ "modelSelector.specialtySavedRoutingOff":
96
+ "声明为 {specialty} 的任务将使用 {value}。若要从任务文本自动识别,需开启 task.modelRouting.enabled。",
97
+ "modelSelector.specialtyCleared": "已清除 {target} 的细分用途设置。",
82
98
  "modelSelector.reasoningFor": "{target} 的推理级别:{id}",
83
99
  "modelSelector.temporaryModel": "临时模型",
84
100
  };