/** * Role loading for the `/role` command. * * The file format stays the claude-harness format: a global layer * (`~/.dsh/roles/.md`, falling back to `~/.claude/roles`), and an optional * project layer (`/.claude/roles/.md`). The project layer wins * on conflict. Auto-memory is Claude Code-only; this port explicitly drops that * step and leaves the role's "Читать на старте" section to the model. */ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' export const DEFAULT_ROLE_NAMES = ['dev', 'admin', 'analyst', 'qa'] const DEFAULT_ROLES_DIR = join(homedir(), '.dsh', 'roles') /** Parse `/role [name]` without owning any grammar beyond the first word. */ export function parseRole(rawInput, roleNames = DEFAULT_ROLE_NAMES) { const input = String(rawInput ?? '').trim() if (input === '') return { kind: 'list' } const role = input.split(/\s+/u)[0] if (!roleNames.includes(role)) return { kind: 'invalid', role } return { kind: 'role', role } } /** Absolute paths of both layers for one role. */ export function rolePaths({ role, cwd, rolesDir = DEFAULT_ROLES_DIR, projectRolesDir = '' }) { const globalPath = join(rolesDir, `${role}.md`) const projectPath = join(projectRolesDir || join(cwd, '.claude', 'roles'), `${role}.md`) return { globalPath, projectPath } } function readIfPresent(path) { try { return existsSync(path) ? readFileSync(path, 'utf8') : undefined } catch { return undefined } } /** Render the list shown by a bare `/role`. */ export function listRoles({ cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) { const lines = [ `Роли claude-harness; общий слой: ${rolesDir || DEFAULT_ROLES_DIR}`, `Workspace: ${cwd}`, '', ] for (const role of roleNames) { const { globalPath, projectPath } = rolePaths({ role, cwd, rolesDir, projectRolesDir }) const global = existsSync(globalPath) ? 'есть' : 'нет' const project = existsSync(projectPath) ? 'есть' : 'нет' lines.push(`- ${role}: общий — ${global}; проектный — ${project}`) } lines.push('', 'Вызов: /role dev | /role admin | /role analyst | /role qa') return lines.join('\n') } /** * Build the model-visible role instructions. * @throws when the global role file is missing. */ export function buildRoleText({ role, cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) { if (!roleNames.includes(role)) throw new Error(`Неизвестная роль: ${role}`) const { globalPath, projectPath } = rolePaths({ role, cwd, rolesDir, projectRolesDir }) const globalText = readIfPresent(globalPath) if (globalText === undefined) { throw new Error(`Нет общего файла роли: ${globalPath}`) } const projectText = readIfPresent(projectPath) const projectMissing = projectText === undefined const sections = [ `[dsh-harness] Роль сессии: ${role}. Workspace: ${cwd}`, 'Новая роль заменяет предыдущую целиком: правила прежней роли больше не действуют.', '', `## Общий слой: ${globalPath}`, globalText.trim(), '', ] if (projectMissing) { sections.push( `## Проектный слой: ${projectPath}`, `Проектного файла роли ${role} нет — работаю по общему слою.`, '', ) } else { sections.push(`## Проектный слой: ${projectPath}`, projectText.trim(), '') } sections.push( '## Память', 'Auto-memory в DeepSeek Harness нет. Раздел «Читать на старте» выполни сам: пути читай через read,', 'команды запускай через bash и только на чтение. Строки с префиксом `brief:` в этом spike не поддерживаются —', 'пропусти их и скажи об этом одной строкой.', '', '## Ответ', 'Ответь ровно пятью строками:', '- Роль: — <описание из первой строки файла роли>.', '- Процесс: <одна строка из раздела «Процесс» — что действует по умолчанию>.', '- Прочитано: <перечень файлов обоих слоёв и файлов памяти, через запятую>.', '- Не делать: <раздел «Не делать» одной строкой>.', '- Ждёт решения владельца: <из памяти роли; если нет — «ничего»>.', ) return { text: sections.join('\n'), globalPath, projectPath, projectMissing, } }