feat(dsh): prestep — detectAdoption/detectRole/тексты инъекций (#43)
This commit is contained in:
parent
03a7793456
commit
6e0828e6e7
164
dsh/bundle/lib/prestep.js
Normal file
164
dsh/bundle/lib/prestep.js
Normal 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
214
dsh/tests/prestep.test.mjs
Normal 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')
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user