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