/** * Pre-step injections of the DSH harness. * * Claude Code runs two SessionStart hooks: `harness-check` (an unadopted project * is offered the setup steps) and `role-hint` (the session title hints the * role). DSH has no shell hook layer, so both are injected once per session * from `agent/pre-step`, with the same marker machinery as the bootstrap. * * This module is pure: fs reads only, no ctx services, no spawn. The wiring * decides when to call it. */ import { createHash } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' const SECTION_LABEL = 'секция «OpenSpec и superpowers» в CLAUDE.md' const SECTION_RE = /^##[ \t]+OpenSpec и superpowers[ \t]*\r?$/m /** Project ownership signals, in the order they are reported. */ const SIGNALS = [ { kind: 'dir', name: 'openspec', label: 'openspec/' }, { kind: 'dir', name: '.codegraph', label: '.codegraph/' }, { kind: 'section', name: 'CLAUDE.md', label: SECTION_LABEL }, ] /** Keyword classes, highest priority first; the first matching class wins. */ const ROLE_KEYWORDS = [ ['admin', ['jira', 'гант', 'спринт']], ['qa', ['стенд', 'прогон', 'скриншот', 'макет']], ['analyst', ['тз', 'oq', 'спека', 'методика']], ['dev', ['срез', 'issue', 'pr', 'тест', 'миграция']], ] /** `pr` is too short for a substring match: it must be a whole word. */ const PR_WORD = /\bpr\b/u function exists(path) { try { statSync(path) return true } catch { return false } } function isDirectory(path) { try { return statSync(path).isDirectory() } catch { return false } } function readText(path) { try { return readFileSync(path, 'utf8') } catch { return undefined } } function hasSection(text) { return typeof text === 'string' && SECTION_RE.test(text) } function findRepoRoot(cwd) { let dir = resolve(cwd || '.') for (;;) { if (exists(join(dir, '.git'))) return dir const parent = dirname(dir) if (parent === dir) return null dir = parent } } /** * Whether the project around `cwd` is wired into the harness. * * Walks up to the nearest `.git` (directory or file — worktrees use a file). * Outside any repository the answer is "adopted" so the check stays silent. * @param {string} cwd - session working directory * @returns {{adopted: boolean, root: string|null, missing: string[]}} */ export function detectAdoption(cwd) { const root = findRepoRoot(cwd) if (root === null) return { adopted: true, root: null, missing: [] } const missing = [] for (const signal of SIGNALS) { const present = signal.kind === 'dir' ? isDirectory(join(root, signal.name)) : hasSection(readText(join(root, signal.name))) if (!present) missing.push(signal.label) } return { adopted: missing.length === 0, root, missing } } /** Non-blocking harness-check text: what is missing and how to set it up. */ export function buildHarnessCheckText(root, missing) { const absent = Array.isArray(missing) ? missing : [] return [ `[dsh-harness] Проект ${root} не подключён к обвязке: нет: ${absent.join('; ')}.`, 'Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.', ].join(' ') } function matchesKeyword(text, keyword) { return keyword === 'pr' ? PR_WORD.test(text) : text.includes(keyword) } /** * Guess the session role from the first user message. * * Keywords are matched case-insensitively; Russian keywords, `jira`, `issue`, * `oq`, and `тз` are substrings, `pr` is a whole word only. * @param {string} firstUserText * @returns {'dev'|'admin'|'analyst'|'qa'|null} */ export function detectRole(firstUserText) { if (typeof firstUserText !== 'string' || firstUserText.trim() === '') return null const text = firstUserText.toLowerCase() for (const [role, keywords] of ROLE_KEYWORDS) { if (keywords.some((keyword) => matchesKeyword(text, keyword))) return role } return null } /** Role-hint text: a concrete role, or one question with the four variants. */ export function buildRoleHintText(role) { if (role) return `[dsh-harness] Роль сессии: ${role}. Вызови skill role. Если не так — скажи.` return ( '[dsh-harness] Роль сессии неясна. Вызови skill role: dev — код, issue, PR, тест, миграция; ' + 'admin — Jira, Гант, спринт; analyst — ТЗ, OQ, спека, методика; qa — стенд, прогон, скриншот, макет. ' + 'Задай владельцу один вопрос с этими четырьмя вариантами.' ) } /** * Whether a message was produced by the harness itself (bootstrap, a previous * injection, or the legacy Claude Code prefix). Such a message is not the * "first user message" for the role hint. */ export function isHarnessMessage(text) { if (typeof text !== 'string') return false return ( text.startsWith('` return { marker, body: `${marker}\n${value}` } }