feat(dsh): prestep — detectAdoption/detectRole/тексты инъекций (#43)

This commit is contained in:
Andrey Epifancev 2026-10-25 11:36:11 +04:00
parent 03a7793456
commit 6e0828e6e7
2 changed files with 378 additions and 0 deletions

164
dsh/bundle/lib/prestep.js Normal file
View File

@ -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('<!-- DSH-HARNESS-') ||
text.includes('[dsh-harness]') ||
text.includes('[claude-harness]')
)
}
/**
* Idempotency marker for a pre-step injection.
* @param {string} text - injection body, hashed to make the marker unique
* @returns {{marker: string, body: string}}
*/
export function makePrestepMarker(text) {
const value = typeof text === 'string' ? text : String(text ?? '')
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16)
const marker = `<!-- DSH-HARNESS-PRESTEP:${hash} -->`
return { marker, body: `${marker}\n${value}` }
}

214
dsh/tests/prestep.test.mjs Normal file
View File

@ -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('<!-- DSH-HARNESS-BOOTSTRAP:abc123 -->\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, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->$/)
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')
})