diff --git a/src/renderer/src/components/ChatTile.tsx b/src/renderer/src/components/ChatTile.tsx
index 0f48cfda..0abaf154 100644
--- a/src/renderer/src/components/ChatTile.tsx
+++ b/src/renderer/src/components/ChatTile.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, Suspense } from 'react'
import type { AppSettings, Persona } from '../../../shared/types'
import { loadPersonas, getAgentIcon, DEFAULT_PERSONAS } from '../config/agentModes'
-import { resolvePersonaModelSeed } from '../hooks/personaModelBinding'
+import { resolvePersonaModelSeed, resolveSkillModelLock } from '../hooks/personaModelBinding'
import { MONO_DEFAULT } from '../FontContext'
const LazyTerminalTile = React.lazy(() => import('./TerminalTile').then(m => ({ default: m.TerminalTile })))
@@ -235,6 +235,24 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid
() => agentModes.find(a => a.id === agentId) ?? null,
[agentModes, agentId],
)
+ // Precedence LAYER 1 (P1b-2): a linked skill's `requiredModel` HARD-locks the
+ // composer. Computed from the active persona + discovered workspace skills; when
+ // non-null the model/provider pills are disabled and the live state is pinned.
+ const modelLock = useMemo(
+ () => resolveSkillModelLock(resolvedAgentMode, workspaceSkills),
+ [resolvedAgentMode, workspaceSkills],
+ )
+ // Keep the live composer state pinned while a lock is active. This closes the
+ // gap onSelectAgent can't: an async skill load or a restored `agentId` (where
+ // the click handler never fires) would otherwise leave a stale model behind a
+ // disabled pill. Early-return when unlocked so layer 3 (user pick) is untouched.
+ // This effect only forces the HARD lock — the soft seed (layer 2) still happens
+ // exclusively in the onSelectAgent click handler, never in an effect.
+ useEffect(() => {
+ if (!modelLock) return
+ if (modelLock.provider) setProvider(modelLock.provider)
+ if (modelLock.model) setModel(modelLock.model)
+ }, [modelLock?.provider, modelLock?.model])
const {
chatSurfaceMenu,
@@ -943,6 +961,8 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid
showModelMenu={showModelMenu}
currentProviderEntry={currentProviderEntry}
currentModelLabel={currentModel.label}
+ modelLocked={Boolean(modelLock)}
+ lockReason={modelLock?.reason}
model={model}
modelFilter={modelFilter}
onModelFilterChange={setModelFilter}
@@ -1014,14 +1034,24 @@ export function ChatTile({ tileId, workspaceId, workspaceDir: _workspaceDir, wid
agentModes={agentModes}
onSelectAgent={nextAgentId => {
setAgentId(nextAgentId)
- // Precedence layer 2: a selected persona's SOFT defaultBinding seeds the
- // composer's provider/model. Seed once here (NOT in an effect — an effect
- // keyed on agentId would re-clobber the user's pick on restore/re-render).
- // The user can freely change it afterward; the live composer state flows
- // to req.model/provider, so the user pick (layer 3) always wins.
- const modelSeed = resolvePersonaModelSeed(agentModes.find(a => a.id === nextAgentId) ?? null)
- if (modelSeed?.provider) setProvider(modelSeed.provider)
- if (modelSeed?.model) setModel(modelSeed.model)
+ const nextPersona = agentModes.find(a => a.id === nextAgentId) ?? null
+ // Precedence LAYER 1 (P1b-2): if a linked skill imposes a HARD model lock,
+ // it PINS provider/model and SHORT-CIRCUITS the soft seed below. The picker
+ // is disabled in the composer; the live-state effect keeps the pin honoured.
+ const skillLock = resolveSkillModelLock(nextPersona, workspaceSkills)
+ if (skillLock) {
+ if (skillLock.provider) setProvider(skillLock.provider)
+ if (skillLock.model) setModel(skillLock.model)
+ } else {
+ // Precedence layer 2: a selected persona's SOFT defaultBinding seeds the
+ // composer's provider/model. Seed once here (NOT in an effect — an effect
+ // keyed on agentId would re-clobber the user's pick on restore/re-render).
+ // The user can freely change it afterward; the live composer state flows
+ // to req.model/provider, so the user pick (layer 3) always wins.
+ const modelSeed = resolvePersonaModelSeed(nextPersona)
+ if (modelSeed?.provider) setProvider(modelSeed.provider)
+ if (modelSeed?.model) setModel(modelSeed.model)
+ }
setShowAgentMenu(false)
}}
planTodos={planTodos}
diff --git a/src/renderer/src/components/CustomisationTile.tsx b/src/renderer/src/components/CustomisationTile.tsx
index 800183f9..c982c79c 100644
--- a/src/renderer/src/components/CustomisationTile.tsx
+++ b/src/renderer/src/components/CustomisationTile.tsx
@@ -4,6 +4,7 @@ import { useAppFonts } from '../FontContext'
import type { PromptTemplate, PromptField, SkillDefinition, Persona } from '../../../shared/types'
import { ChatMarkdown } from './shared/streamdown-utils'
import { DEFAULT_PERSONAS as DEFAULT_MODES, AGENT_COLORS, AGENT_ICONS } from '../config/agentModes'
+import { useChatTileWorkspaceSkills } from '../hooks/useChatTileWorkspaceSkills'
type Tab = 'prompts' | 'skills' | 'tools' | 'agents'
@@ -794,6 +795,12 @@ function SkillEditor({ item, onSave, onCancel }: { item: SkillDefinition; onSave
up({ name: v })} placeholder="Skill name" />
up({ description: v })} placeholder="What does this skill do?" />
up({ command: v })} placeholder="e.g. my-skill" />
+
+ up({ requiredModel: v.trim() || undefined })} placeholder="e.g. claude-opus-4-8 — pins the composer model when a persona links this skill" />
+
+
+ up({ requiredProvider: v.trim() || undefined })} placeholder="optional — e.g. claude, codex (pins alongside the model)" />
+
up({ content: v })} placeholder="# Skill content..." multiline mono rows={12} />
Cancel
@@ -1203,7 +1210,7 @@ export function AgentsSection({ workspacePath, hideHeaderText = false }: { works
scanDirs()
}, [workspacePath, locationText])
- if (editing) return
setEditing(null)} />
+ if (editing) return setEditing(null)} />
if (locationsOpen) return (
void; onCancel: () => void }): JSX.Element {
+function AgentEditor({ item, modes, workspacePath, onSave, onCancel }: { item: Persona; modes: Persona[]; workspacePath: string; onSave: (m: Persona) => void; onCancel: () => void }): JSX.Element {
const theme = useTheme()
const fonts = useAppFonts()
const [draft, setDraft] = useState(item)
const up = (patch: Partial) => setDraft(prev => ({ ...prev, ...patch }))
+ // Discovered + authored skills for the link picker. Linked skills are stored by
+ // NAME (stable across machines; discovered ids are path-based). A skill carrying a
+ // `requiredModel` HARD-locks the composer model for any persona that links it.
+ const { workspaceSkills } = useChatTileWorkspaceSkills(workspacePath)
+ const linkedSkills = draft.skills ?? []
+ const toggleSkill = (name: string) => up({
+ skills: linkedSkills.includes(name)
+ ? linkedSkills.filter(s => s !== name)
+ : [...linkedSkills, name],
+ })
// tools semantics: null/undefined (unset) = unrestricted → checkbox off;
// [] = explicit deny-all and [names] = restricted → checkbox on. Loose `!=`
// so an absent (undefined) tools field reads as unrestricted, not restricted.
@@ -1302,6 +1319,36 @@ function AgentEditor({ item, modes, onSave, onCancel }: { item: Persona; modes:
)}
+
+ {workspaceSkills.length === 0 ? (
+
+ No workspace skills found. Skills that declare a Required Model will pin this persona’s model when linked.
+
+ ) : (
+
+ {workspaceSkills.map(skill => {
+ const checked = linkedSkills.includes(skill.name)
+ return (
+
+ toggleSkill(skill.name)} />
+ {skill.name}
+ {skill.requiredModel && (
+
+ locks {skill.requiredModel}
+
+ )}
+
+ )
+ })}
+
+ )}
+ {linkedSkills.some(name => !workspaceSkills.find(s => s.name === name)) && (
+
+ Linked (not in current workspace): {linkedSkills.filter(name => !workspaceSkills.find(s => s.name === name)).join(', ')}
+
+ )}
+
+
up({ defaultNextMode: e.target.value || undefined })}
style={{ padding: '6px 10px', fontSize: fonts.secondarySize, borderRadius: 6, background: theme.surface.input, color: theme.text.secondary, border: `1px solid ${theme.border.default}`, outline: 'none' }}>
diff --git a/src/renderer/src/components/chat/ChatTileComposer.tsx b/src/renderer/src/components/chat/ChatTileComposer.tsx
index e3ecf08c..66264f6b 100644
--- a/src/renderer/src/components/chat/ChatTileComposer.tsx
+++ b/src/renderer/src/components/chat/ChatTileComposer.tsx
@@ -185,6 +185,10 @@ export interface ChatTileComposerProps {
showModelMenu: boolean
currentProviderEntry: ProviderEntry | undefined
currentModelLabel: string
+ /** P1b-2 (layer 1): a linked skill pins the model — disable the model/provider pickers. */
+ modelLocked?: boolean
+ /** Tooltip explaining the lock, shown on the disabled pills. */
+ lockReason?: string
model: string
modelFilter: string
onModelFilterChange: (value: string) => void
@@ -318,6 +322,8 @@ export function ChatTileComposer({
showModelMenu,
currentProviderEntry,
currentModelLabel,
+ modelLocked,
+ lockReason,
model,
modelFilter,
onModelFilterChange,
@@ -523,7 +529,8 @@ export function ChatTileComposer({
label={currentProviderEntry?.label ?? 'Provider'}
active={showProviderMenu}
onClick={() => onToggleMenu('provider')}
- title="Choose the CLI agent (hidden once the conversation starts)"
+ disabled={modelLocked}
+ title={modelLocked ? (lockReason ?? 'Model locked') : 'Choose the CLI agent (hidden once the conversation starts)'}
/>
{showProviderMenu && (
@@ -550,6 +557,8 @@ export function ChatTileComposer({
label={currentModelLabel}
active={showModelMenu}
onClick={() => onToggleMenu('model')}
+ disabled={modelLocked}
+ title={modelLocked ? (lockReason ?? 'Model locked') : undefined}
/>
{showModelMenu && (
diff --git a/src/renderer/src/hooks/personaModelBinding.ts b/src/renderer/src/hooks/personaModelBinding.ts
index 9444421e..094fd44a 100644
--- a/src/renderer/src/hooks/personaModelBinding.ts
+++ b/src/renderer/src/hooks/personaModelBinding.ts
@@ -1,4 +1,4 @@
-import type { Persona, PersonaBinding } from '../../../shared/types'
+import type { Persona, PersonaBinding, SkillDefinition } from '../../../shared/types'
// ─── Persona soft model/provider seeding (P1b-1) ──────────────────────────────
// Dissociates model from persona IDENTITY: a Persona carries at most an OPTIONAL
@@ -28,8 +28,14 @@ export interface PersonaModelSeed {
provider?: string
/** Model id to seed into the composer, if the binding specifies one. */
model?: string
- // SEAM (P1b-2): a hard skill-lock will add `locked?: boolean` (+ reason) here so
- // callers can disable the composer control without reshaping this result.
+ /**
+ * P1b-2 (layer 1): true when this is a HARD skill-lock (from resolveSkillModelLock)
+ * rather than a soft seed. Callers disable the composer's model/provider picker and
+ * force the live state to provider/model. Unset/false for soft seeds (layer 2).
+ */
+ locked?: boolean
+ /** Human-readable explanation of the lock, surfaced as the disabled pill's tooltip. */
+ reason?: string
}
function cleanField(value: string | undefined): string | undefined {
@@ -38,6 +44,44 @@ function cleanField(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
+/**
+ * Resolve the HARD skill-defined model lock for a persona (precedence LAYER 1 —
+ * runs ABOVE resolvePersonaModelSeed). A persona may LINK skills via `persona.skills`
+ * (matched against `workspaceSkills` by `id` OR `name`). The FIRST linked skill that
+ * declares a `requiredModel` wins: its model (and `requiredProvider`, if set) PIN the
+ * composer and the picker is disabled. Returns null when no linked skill imposes a
+ * lock, so the caller falls through to the soft default (layer 2) / user pick (layer 3).
+ *
+ * model is NOT a security boundary: this drives composer disablement only and never
+ * touches resolveAuthoritativeAgentMode (the trusted-disk tools/permission path).
+ */
+export function resolveSkillModelLock(
+ persona: Persona | null | undefined,
+ workspaceSkills: SkillDefinition[] | null | undefined,
+): PersonaModelSeed | null {
+ const linked = persona?.skills
+ if (!Array.isArray(linked) || linked.length === 0) return null
+ if (!Array.isArray(workspaceSkills) || workspaceSkills.length === 0) return null
+ for (const ref of linked) {
+ const key = cleanField(typeof ref === 'string' ? ref : undefined)
+ if (!key) continue
+ const skill = workspaceSkills.find(s => s?.id === key || s?.name === key)
+ if (!skill) continue
+ // requiredModel is the lock TRIGGER ("first linked skill with requiredModel wins").
+ const model = cleanField(skill.requiredModel)
+ if (!model) continue
+ const provider = cleanField(skill.requiredProvider)
+ const lock: PersonaModelSeed = {
+ model,
+ locked: true,
+ reason: `Model locked by skill "${skill.name}"`,
+ }
+ if (provider) lock.provider = provider
+ return lock
+ }
+ return null
+}
+
/**
* Resolve the SOFT model seed for a persona (precedence layer 2). Returns null
* when the persona carries no usable soft default, so the caller leaves the
diff --git a/src/renderer/src/hooks/useChatTileWorkspaceSkills.ts b/src/renderer/src/hooks/useChatTileWorkspaceSkills.ts
index 3a2fad61..374fea7a 100644
--- a/src/renderer/src/hooks/useChatTileWorkspaceSkills.ts
+++ b/src/renderer/src/hooks/useChatTileWorkspaceSkills.ts
@@ -90,15 +90,23 @@ export function useChatTileWorkspaceSkills(workspaceDir: string) {
})
const registerDiscoveredSkill = (filePath: string, fallbackName: string, content: string, dir: string): void => {
- const nameMatch = content.match(/^---[\s\S]*?name:\s*(.+?)$/m)
- const descriptionMatch = content.match(/^---[\s\S]*?description:\s*(.+?)$/m)
- const name = nameMatch?.[1]?.trim() ?? fallbackName
+ const frontmatter = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''
+ const name = frontmatter.match(/^name:\s*(.+?)\s*$/m)?.[1]?.trim() ?? fallbackName
+ const description = frontmatter.match(/^description:\s*(.+?)\s*$/m)?.[1]?.trim() ?? `From ${dir}`
+ // P1b-2: an optional `model:`/`provider:` frontmatter key declares a HARD
+ // model lock for any persona that links this skill (see resolveSkillModelLock).
+ // Parse them ONLY from the leading `---`-fenced frontmatter block, line-anchored,
+ // so prose like "pick the best model: fast" in the body never trips a spurious lock.
+ const requiredModel = frontmatter.match(/^model:\s*(.+?)\s*$/m)?.[1]?.trim() || undefined
+ const requiredProvider = frontmatter.match(/^provider:\s*(.+?)\s*$/m)?.[1]?.trim() || undefined
registerSkill({
id: `discovered-${filePath}`,
name,
- description: descriptionMatch?.[1]?.trim() ?? `From ${dir}`,
+ description,
content,
command: name,
+ ...(requiredModel ? { requiredModel } : {}),
+ ...(requiredProvider ? { requiredProvider } : {}),
})
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 6ed2536d..dbfb3c3a 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -116,6 +116,19 @@ export interface SkillDefinition {
description: string
content: string
command?: string
+ /**
+ * OPTIONAL hard model/provider requirement (P1b-2, precedence layer 1). When a
+ * persona LINKS a skill that declares `requiredModel`, selecting that persona
+ * PINS the composer's model (+ provider, if given) and DISABLES the picker —
+ * an outer resolver (resolveSkillModelLock) runs ABOVE the soft default.
+ *
+ * Like a persona's soft binding, this is NOT a security boundary: it only
+ * drives renderer model-resolution + composer disablement; it never flows
+ * through resolveAuthoritativeAgentMode (the trusted-disk tools/permission path).
+ */
+ requiredModel?: string
+ /** OPTIONAL provider to pin alongside `requiredModel`. */
+ requiredProvider?: string
}
/**
@@ -166,6 +179,17 @@ export interface Persona {
* See overlayPersonas() in src/shared/agentModes.ts.
*/
extends?: string
+ /**
+ * OPTIONAL linked skill references (P1b-2, precedence layer 1). Each entry
+ * matches a workspace skill by `id` OR `name`. If the FIRST linked skill that
+ * declares a `requiredModel` is found, selecting this persona HARD-locks the
+ * composer's model/provider and disables the picker (see resolveSkillModelLock).
+ *
+ * Built-ins carry NO skills (keeps DEFAULT_PERSONAS byte-identical across the
+ * shared<->daemon drift guard). Like `defaultBinding`, this is display/composer
+ * data only — never a permission boundary.
+ */
+ skills?: string[]
/** Which tool this persona was discovered from: 'claude' | 'cursor' | 'opencode' | 'gemini' | etc. */
source?: string
}
diff --git a/test/daemon/persona-model-binding.test.mjs b/test/daemon/persona-model-binding.test.mjs
index 7318f455..93099960 100644
--- a/test/daemon/persona-model-binding.test.mjs
+++ b/test/daemon/persona-model-binding.test.mjs
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// Pure renderer resolver (type-only imports → loads under node's type-stripping).
-import { resolvePersonaModelSeed } from '../../src/renderer/src/hooks/personaModelBinding.ts'
+import { resolvePersonaModelSeed, resolveSkillModelLock } from '../../src/renderer/src/hooks/personaModelBinding.ts'
// Daemon-side mirror (consumed by the CLI; must NOT import renderer code).
import { resolvePersonaModelSeed as daemonResolvePersonaModelSeed } from '../../packages/codesurf-daemon/src/persona-model-binding.ts'
// Overlay/inheritance: confirm `defaultBinding` survives overlay + extends merge.
@@ -208,3 +208,187 @@ test('altitude: defaultBinding does NOT flow through resolveAuthoritativeAgentMo
await rmP(root, { recursive: true, force: true })
}
})
+
+// ═══ P1b-2: SKILL-DEFINED MODEL LOCK (precedence LAYER 1) ══════════════════════
+// A Persona LINKS skills (`persona.skills`, matched against discovered workspace
+// skills by id OR name). If the first linked skill declares a `requiredModel`,
+// selecting the persona HARD-locks the composer's model/provider (layer 1 — runs
+// ABOVE the soft default and the user pick) and the picker is disabled. Like the
+// soft binding, the lock is renderer/composer-only and NEVER a permission boundary.
+
+const SKILL_WITH_LOCK = { id: 'discovered-/x/review.md', name: 'review', description: '', content: '', requiredModel: 'claude-opus-4-8', requiredProvider: 'claude' }
+const SKILL_MODEL_ONLY = { id: 'plain', name: 'plain', description: '', content: '', requiredModel: 'gpt-5' }
+const SKILL_NO_LOCK = { id: 'free', name: 'free', description: '', content: '' }
+
+// ─── layer 1 OVERRIDES the soft default ───────────────────────────────────────
+
+test('lock OVERRIDES the soft default: a linked skill\'s requiredModel wins over defaultBinding', () => {
+ // Persona carries BOTH a soft default (layer 2) AND a linked locking skill (layer 1).
+ const persona = {
+ id: 'reviewer', name: 'Reviewer', description: '', systemPrompt: 'S', tools: null,
+ icon: 'star', color: '#b368c9', isBuiltin: false,
+ defaultBinding: { provider: 'codex', model: 'gpt-5' },
+ skills: ['review'],
+ }
+ const lock = resolveSkillModelLock(persona, [SKILL_WITH_LOCK])
+ assert.deepEqual(lock, { model: 'claude-opus-4-8', locked: true, reason: 'Model locked by skill "review"', provider: 'claude' })
+ // The lock model must differ from (and therefore override) the soft default.
+ const soft = resolvePersonaModelSeed(persona)
+ assert.deepEqual(soft, { provider: 'codex', model: 'gpt-5' }, 'the soft layer still resolves independently...')
+ assert.notEqual(lock.model, soft.model, '...but the caller takes the lock first (layer 1 above layer 2)')
+})
+
+test('lock: matches a linked skill by id OR by name', () => {
+ const byName = { id: 'p', skills: ['review'] }
+ const byId = { id: 'p', skills: ['discovered-/x/review.md'] }
+ assert.equal(resolveSkillModelLock(byName, [SKILL_WITH_LOCK])?.model, 'claude-opus-4-8')
+ assert.equal(resolveSkillModelLock(byId, [SKILL_WITH_LOCK])?.model, 'claude-opus-4-8')
+})
+
+test('lock: model-only requiredModel pins the model with no provider', () => {
+ assert.deepEqual(
+ resolveSkillModelLock({ id: 'p', skills: ['plain'] }, [SKILL_MODEL_ONLY]),
+ { model: 'gpt-5', locked: true, reason: 'Model locked by skill "plain"' },
+ )
+})
+
+test('lock: FIRST linked skill that declares a requiredModel wins (order = persona.skills)', () => {
+ const persona = { id: 'p', skills: ['free', 'plain', 'review'] }
+ // `free` has no requiredModel (skipped); `plain` is the first that locks.
+ assert.equal(resolveSkillModelLock(persona, [SKILL_NO_LOCK, SKILL_MODEL_ONLY, SKILL_WITH_LOCK])?.model, 'gpt-5')
+})
+
+// ─── no lock → null (fall through to layers 2/3) ──────────────────────────────
+
+test('no linked skill => null (composer falls through to the soft default / user pick)', () => {
+ assert.equal(resolveSkillModelLock({ id: 'p' }, [SKILL_WITH_LOCK]), null, 'persona without skills => no lock')
+ assert.equal(resolveSkillModelLock({ id: 'p', skills: [] }, [SKILL_WITH_LOCK]), null, 'empty skills => no lock')
+ assert.equal(resolveSkillModelLock({ id: 'p', skills: ['review'] }, []), null, 'no workspace skills => no lock')
+ assert.equal(resolveSkillModelLock({ id: 'p', skills: ['review'] }, [SKILL_NO_LOCK]), null, 'linked skill carries no requiredModel => no lock')
+ assert.equal(resolveSkillModelLock({ id: 'p', skills: ['ghost'] }, [SKILL_WITH_LOCK]), null, 'linked id not found => no lock')
+ assert.equal(resolveSkillModelLock(null, [SKILL_WITH_LOCK]), null)
+ assert.equal(resolveSkillModelLock(undefined, [SKILL_WITH_LOCK]), null)
+ assert.equal(resolveSkillModelLock({ id: 'p', skills: ['review'] }, null), null)
+})
+
+test('no lock: all BUILT-IN personas carry no skills → never locked', () => {
+ for (const p of overlayPersonas(null)) {
+ assert.equal(resolveSkillModelLock(p, [SKILL_WITH_LOCK]), null, `${p.id} must carry no linked skills`)
+ }
+})
+
+// ─── frontmatter parse: model:/provider: → requiredModel/requiredProvider ──────
+
+test('discovery: registerDiscoveredSkill parses model:/provider: frontmatter into requiredModel/requiredProvider', () => {
+ const src = readFileSync(join(ROOT_DIR, 'src/renderer/src/hooks/useChatTileWorkspaceSkills.ts'), 'utf8')
+ assert.ok(src.includes('requiredModel'), 'must set requiredModel on the discovered skill')
+ assert.ok(src.includes('requiredProvider'), 'must set requiredProvider on the discovered skill')
+ // The keys must be parsed from a BOUNDED, leading `---`-fenced frontmatter block —
+ // NOT an unbounded `[\s\S]*?model:` scan that would false-positive on body prose
+ // like "pick the best model: fast".
+ assert.match(src, /const frontmatter = content\.match\(\/\^---/, 'must extract the leading frontmatter block first')
+ assert.match(src, /frontmatter\.match\(\/\^model:/m, 'requiredModel must be line-anchored within the frontmatter block')
+ assert.match(src, /frontmatter\.match\(\/\^provider:/m, 'requiredProvider must be line-anchored within the frontmatter block')
+ assert.doesNotMatch(src, /\[\\s\\S\]\*\?\\bmodel:/, 'must NOT use the unbounded body-spanning model: scan')
+})
+
+// Behavioural check of the SAME parsing logic the hook ships (registerDiscoveredSkill
+// is a closure → not importable; this mirrors its bounded frontmatter parse exactly).
+function parseSkillFrontmatterLock(content) {
+ const frontmatter = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''
+ return {
+ requiredModel: frontmatter.match(/^model:\s*(.+?)\s*$/m)?.[1]?.trim() || undefined,
+ requiredProvider: frontmatter.match(/^provider:\s*(.+?)\s*$/m)?.[1]?.trim() || undefined,
+ }
+}
+
+test('discovery (behaviour): frontmatter model:/provider: lock; body prose never trips a spurious lock', () => {
+ // Real frontmatter declares a lock.
+ assert.deepEqual(
+ parseSkillFrontmatterLock('---\nname: review\nmodel: claude-opus-4-8\nprovider: claude\n---\n\nBody.'),
+ { requiredModel: 'claude-opus-4-8', requiredProvider: 'claude' },
+ )
+ // Prose that merely mentions "model:" in the BODY must NOT become a requiredModel.
+ assert.deepEqual(
+ parseSkillFrontmatterLock('---\nname: helper\ndescription: a helper\n---\n\nPick the best model: fast, then go.'),
+ { requiredModel: undefined, requiredProvider: undefined },
+ )
+ // A "model:" inside a frontmatter description value must not leak as the lock either.
+ assert.deepEqual(
+ parseSkillFrontmatterLock('---\nname: x\ndescription: choose a model: any\n---\nbody'),
+ { requiredModel: undefined, requiredProvider: undefined },
+ )
+})
+
+// ─── composer wiring: locked => model + provider pills disabled ────────────────
+
+test('locked => the model + provider pills are disabled (ChatTileComposer wiring)', () => {
+ const src = readFileSync(join(ROOT_DIR, 'src/renderer/src/components/chat/ChatTileComposer.tsx'), 'utf8')
+ // The model pill (always shown) disables on the lock and surfaces the reason.
+ assert.match(src, /label=\{currentModelLabel\}[\s\S]*?disabled=\{modelLocked\}/, 'the model pill must disable when modelLocked')
+ // The provider pill (shown pre-conversation) disables too.
+ assert.match(src, /label=\{currentProviderEntry\?\.label \?\? 'Provider'\}[\s\S]*?disabled=\{modelLocked\}/, 'the provider pill must disable when modelLocked')
+ assert.match(src, /title=\{modelLocked \? lockReason/, 'the disabled pill must surface the lock reason as its tooltip')
+ // ToolbarPill already drops onClick + renders a Lock glyph when disabled.
+ const controls = readFileSync(join(ROOT_DIR, 'src/renderer/src/components/chat/ChatComposerControls.tsx'), 'utf8')
+ assert.match(controls, /onClick=\{disabled \? undefined : onClick\}/, 'a disabled ToolbarPill must not invoke onClick')
+})
+
+test('ChatTile computes the lock from the active persona + workspace skills and threads it to the composer', () => {
+ const src = readFileSync(join(ROOT_DIR, 'src/renderer/src/components/ChatTile.tsx'), 'utf8')
+ assert.match(src, /resolveSkillModelLock\(resolvedAgentMode,\s*workspaceSkills\)/, 'must compute the lock from the active persona + workspace skills')
+ assert.match(src, /modelLocked=\{Boolean\(modelLock\)\}/, 'must thread modelLocked to the composer')
+ assert.match(src, /lockReason=\{modelLock\?\.reason\}/, 'must thread the lock reason to the composer')
+})
+
+// ─── layer-1 short-circuits layer-2 in the onSelectAgent click handler ─────────
+
+test('onSelectAgent SHORT-CIRCUITS the soft seed when a skill lock applies (layer 1 above layer 2)', () => {
+ const src = readFileSync(join(ROOT_DIR, 'src/renderer/src/components/ChatTile.tsx'), 'utf8')
+ const start = src.indexOf('onSelectAgent={nextAgentId => {')
+ assert.ok(start >= 0, 'onSelectAgent click handler must exist')
+ const block = src.slice(start, src.indexOf('}}', start) + 2)
+ // layer 1 first: a lock pins provider/model...
+ const lockIdx = block.indexOf('resolveSkillModelLock(')
+ const seedIdx = block.indexOf('resolvePersonaModelSeed(')
+ assert.ok(lockIdx >= 0, 'must resolve the skill lock in the handler')
+ assert.ok(seedIdx > lockIdx, 'the lock must be resolved BEFORE the soft seed (layer 1 first)')
+ // ...and the soft seed runs only in the else branch (skipped when locked).
+ assert.match(block, /if \(skillLock\)\s*\{[\s\S]*?\}\s*else\s*\{[\s\S]*?resolvePersonaModelSeed\(/, 'the soft seed must live in the else branch — short-circuited when locked')
+})
+
+// ─── overlay / extends: Persona.skills flows through unchanged ─────────────────
+
+test('overlay: a persisted persona may add `skills`; it survives the overlay (and feeds the lock)', () => {
+ const resolved = overlayPersonas([
+ { id: 'custom', name: 'Custom', description: '', systemPrompt: 'S', tools: ['Read'], icon: 'robot', color: '#111', isBuiltin: false, skills: ['review'] },
+ ])
+ const custom = resolved.find(p => p.id === 'custom')
+ assert.deepEqual(custom.skills, ['review'], 'the linked-skills list survives the overlay')
+ assert.equal(resolveSkillModelLock(custom, [SKILL_WITH_LOCK])?.model, 'claude-opus-4-8')
+})
+
+test('overlay extends: a child inherits the base `skills` unless it defines its own', () => {
+ const resolved = overlayPersonas([
+ { id: 'base', name: 'Base', description: '', systemPrompt: 'S', tools: ['Read'], icon: 'map', color: '#111', isBuiltin: false, skills: ['review'] },
+ { id: 'inheritor', name: 'Inheritor', extends: 'base', isBuiltin: false },
+ { id: 'overrider', name: 'Overrider', extends: 'base', isBuiltin: false, skills: ['plain'] },
+ ])
+ assert.deepEqual(resolved.find(p => p.id === 'inheritor').skills, ['review'], 'omitted skills inherit the base list')
+ assert.deepEqual(resolved.find(p => p.id === 'overrider').skills, ['plain'], 'a child-defined skills list overlays the base')
+})
+
+// ─── drift guard: built-ins carry NO skills (DEFAULT_PERSONAS stays byte-identical) ─
+
+test('drift guard: no built-in persona declares `skills` (keeps shared<->daemon DEFAULT_PERSONAS identical)', () => {
+ for (const p of overlayPersonas(null)) {
+ assert.equal(p.skills, undefined, `${p.id} must NOT declare skills — built-ins stay byte-identical across the drift guard`)
+ }
+})
+
+// ─── ALTITUDE: the skill lock never enters the authoritative tools/permission path ─
+
+test('altitude: the authoritative resolver references NONE of the lock identifiers (model is not a permission boundary)', () => {
+ const resolverSrc = readFileSync(join(ROOT_DIR, 'src/main/chat/agent-mode-resolver.ts'), 'utf8')
+ assert.doesNotMatch(resolverSrc, /requiredModel|requiredProvider|resolveSkillModelLock|\.skills\b/, 'the trusted-disk resolver must not reference the skill-lock surface')
+})