165 lines
5.6 KiB
JavaScript
165 lines
5.6 KiB
JavaScript
/**
|
||
* 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}` }
|
||
}
|