165 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}` }
}