diff --git a/dsh/bundle/lib/prestep.js b/dsh/bundle/lib/prestep.js new file mode 100644 index 0000000..886f4a8 --- /dev/null +++ b/dsh/bundle/lib/prestep.js @@ -0,0 +1,164 @@ +/** + * 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}` } +} diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs new file mode 100644 index 0000000..5b013aa --- /dev/null +++ b/dsh/tests/prestep.test.mjs @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import test from 'node:test' +import { + buildHarnessCheckText, + buildRoleHintText, + detectAdoption, + detectRole, + isHarnessMessage, + makePrestepMarker, +} from '../bundle/lib/prestep.js' +import { cleanup, tempDir, write } from './helpers.mjs' + +const SECTION = '## OpenSpec и superpowers\n\nСекция подключения.\n' + +/** + * A throwaway directory tree that looks like a harness-adopted repository: + * `.git/`, `openspec/`, `.codegraph/`, and the CLAUDE.md section. + */ +function adoptedRepo() { + const repo = tempDir('dsh-harness-adopted-') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), `# Проект\n\n${SECTION}`) + return repo +} + +// --------------------------------------------------------------------------- +// detectAdoption +// --------------------------------------------------------------------------- + +test('detectAdoption: adopted repo reports adopted with no missing signals', () => { + const repo = adoptedRepo() + try { + assert.deepEqual(detectAdoption(repo), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: repo without openspec/ and the CLAUDE.md section is not adopted', () => { + const repo = tempDir('dsh-harness-partial-') + try { + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), '# Проект без секции\n') + + assert.deepEqual(detectAdoption(repo), { + adopted: false, + root: repo, + missing: ['openspec/', 'секция «OpenSpec и superpowers» в CLAUDE.md'], + }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd in a subdirectory walks up to the repo root', () => { + const repo = adoptedRepo() + try { + const subdir = join(repo, 'dsh', 'bundle', 'lib') + write(join(subdir, 'module.js'), 'export {}\n') + + assert.deepEqual(detectAdoption(subdir), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd outside any git repo stays silent', () => { + const outside = tempDir('dsh-harness-outside-') + try { + assert.deepEqual(detectAdoption(outside), { adopted: true, root: null, missing: [] }) + } finally { + cleanup(outside) + } +}) + +test('detectAdoption: a .git file (worktree checkout) is a repo root too', () => { + const repo = tempDir('dsh-harness-worktree-') + try { + write(join(repo, '.git'), `gitdir: ${join(repo, '..', 'main', '.git', 'worktrees', 'x')}\n`) + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), SECTION) + + assert.deepEqual(detectAdoption(join(repo, 'openspec')), { + adopted: true, + root: repo, + missing: [], + }) + } finally { + cleanup(repo) + } +}) + +// --------------------------------------------------------------------------- +// buildHarnessCheckText +// --------------------------------------------------------------------------- + +test('buildHarnessCheckText names the root, missing signals, and next steps', () => { + const text = buildHarnessCheckText('/tmp/proj', [ + 'openspec/', + 'секция «OpenSpec и superpowers» в CLAUDE.md', + ]) + assert.match( + text, + /^\[dsh-harness\] Проект \/tmp\/proj не подключён к обвязке: нет: openspec\/; секция «OpenSpec и superpowers» в CLAUDE\.md\. /, + ) + assert.match(text, /openspec init --tools agents/) + assert.match(text, /codegraph init/) + assert.match(text, /Предложи владельцу настройку/) +}) + +// --------------------------------------------------------------------------- +// detectRole +// --------------------------------------------------------------------------- + +test('detectRole: admin keywords (jira, гант, спринт)', () => { + assert.equal(detectRole('Разбери jira-задачу'), 'admin') + assert.equal(detectRole('Покажи гант проекта'), 'admin') + assert.equal(detectRole('Новый спринт начинается'), 'admin') + assert.equal(detectRole('JIRA-тикет'), 'admin') +}) + +test('detectRole: qa keywords (стенд, прогон, скриншот, макет)', () => { + assert.equal(detectRole('Подними стенд'), 'qa') + assert.equal(detectRole('Нужен прогон сценария'), 'qa') + assert.equal(detectRole('Сделай скриншот'), 'qa') + assert.equal(detectRole('Сверь с макетом'), 'qa') +}) + +test('detectRole: analyst keywords (тз, oq, спека, методика)', () => { + assert.equal(detectRole('Оформи ТЗ'), 'analyst') + assert.equal(detectRole('Закрой OQ'), 'analyst') + assert.equal(detectRole('Спека готова'), 'analyst') + assert.equal(detectRole('Нужна методика'), 'analyst') +}) + +test('detectRole: dev keywords (срез, issue, pr, тест, миграция)', () => { + assert.equal(detectRole('Сними срез метрик'), 'dev') + assert.equal(detectRole('Открой issue'), 'dev') + assert.equal(detectRole('Проверь тест'), 'dev') + assert.equal(detectRole('Нужна миграция'), 'dev') +}) + +test('detectRole: pr matches only as a whole word', () => { + assert.equal(detectRole('сделай PR'), 'dev') + assert.equal(detectRole('просто посмотри'), null) + assert.equal(detectRole('preview the branch'), null) +}) + +test('detectRole: priority is admin before qa, analyst, and dev', () => { + assert.equal(detectRole('спринт и тест'), 'admin') + assert.equal(detectRole('прогон и issue'), 'qa') + assert.equal(detectRole('спека и тест'), 'analyst') +}) + +test('detectRole: no keyword or empty input yields null', () => { + assert.equal(detectRole('Привет, как дела?'), null) + assert.equal(detectRole(''), null) +}) + +// --------------------------------------------------------------------------- +// buildRoleHintText +// --------------------------------------------------------------------------- + +test('buildRoleHintText: known role names it and asks to load the skill', () => { + const text = buildRoleHintText('qa') + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии: qa\./) + assert.match(text, /Вызови skill role\./) +}) + +test('buildRoleHintText: null asks one question with all four roles', () => { + const text = buildRoleHintText(null) + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии неясна\./) + for (const role of ['dev', 'admin', 'analyst', 'qa']) { + assert.ok(text.includes(`${role} —`), `${role} variant is listed`) + } + assert.match(text, /четырьмя вариантами/) +}) + +// --------------------------------------------------------------------------- +// isHarnessMessage +// --------------------------------------------------------------------------- + +test('isHarnessMessage: bootstrap, DSH, and legacy claude-harness texts are harness messages', () => { + assert.equal(isHarnessMessage('\nYou have superpowers.'), true) + assert.equal(isHarnessMessage('[dsh-harness] Роль сессии: dev.'), true) + assert.equal(isHarnessMessage('[claude-harness] Роль сессии: dev'), true) +}) + +test('isHarnessMessage: plain user text is not a harness message', () => { + assert.equal(isHarnessMessage('Привет! Разбери задачу.'), false) + assert.equal(isHarnessMessage(''), false) +}) + +// --------------------------------------------------------------------------- +// makePrestepMarker +// --------------------------------------------------------------------------- + +test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', () => { + const { marker, body } = makePrestepMarker('Привет') + assert.match(marker, /^$/) + assert.ok(body.startsWith(marker)) + assert.equal(body, `${marker}\nПривет`) + + assert.equal(makePrestepMarker('Привет').marker, marker, 'same text, same marker') + assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker') + assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters') +})