433 lines
17 KiB
JavaScript
433 lines
17 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { join } from 'node:path'
|
||
import test from 'node:test'
|
||
import { apply } from '../bundle/index.js'
|
||
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')
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// apply(): wiring of harness-check and role-hint into agent/pre-step
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Text of the first text block of a message. */
|
||
function messageText(message) {
|
||
return message.content[0].text
|
||
}
|
||
|
||
/**
|
||
* apply() against a fake ctx: the `agent/pre-step` listener is captured and
|
||
* can be run with a canned decision. The session cwd is served by the fake
|
||
* workingDirectory; bootstrap is off in most cases so its big text does not
|
||
* pollute the message lists.
|
||
*/
|
||
function prestepHarness(config = {}, cwd) {
|
||
const listeners = {}
|
||
const ctx = {
|
||
tools: { guard() {} },
|
||
on(event, handler) { listeners[event] = handler },
|
||
inject() {},
|
||
workingDirectory: { get() { return cwd } },
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
}
|
||
apply(ctx, config)
|
||
const handler = listeners['agent/pre-step']
|
||
assert.ok(handler, 'agent/pre-step listener is registered')
|
||
return {
|
||
run({ agent = {}, messages = [] } = {}) {
|
||
return handler({ agent, signal: {} }, async () => ({ kind: 'continue', messages }))
|
||
},
|
||
}
|
||
}
|
||
|
||
/** A tree with `.git` but none of the adoption signals. */
|
||
function nonAdoptedRepo() {
|
||
const repo = tempDir('dsh-harness-noadopt-')
|
||
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||
write(join(repo, 'CLAUDE.md'), '# Проект без секции\n')
|
||
return repo
|
||
}
|
||
|
||
function userMessage(text) {
|
||
return { content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||
}
|
||
|
||
test('apply: harness-check injects when adoption signals are missing', async () => {
|
||
const repo = nonAdoptedRepo()
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, repo)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||
|
||
assert.equal(decision.messages.length, 1)
|
||
const text = messageText(decision.messages[0])
|
||
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||
assert.match(text, /\[dsh-harness\] Проект .* не подключён к обвязке/)
|
||
assert.match(text, /openspec init --tools agents/)
|
||
assert.equal(decision.messages[0].source.kind, 'user')
|
||
} finally {
|
||
cleanup(repo)
|
||
}
|
||
})
|
||
|
||
test('apply: harness-check stays silent for an adopted repo and outside git', async () => {
|
||
const repo = adoptedRepo()
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, repo)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||
assert.deepEqual(decision.messages, [])
|
||
} finally {
|
||
cleanup(repo)
|
||
}
|
||
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||
assert.deepEqual(decision.messages, [])
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: role-hint injects Роль сессии: dev for a keyword user message', async () => {
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const user = userMessage('Напиши тест для модуля')
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||
|
||
assert.equal(decision.messages.length, 2)
|
||
const text = messageText(decision.messages[1])
|
||
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||
assert.match(text, /\[dsh-harness\] Роль сессии: dev\./)
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: role-hint asks the four-variant question when no keyword matches', async () => {
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const user = userMessage('Привет, как дела?')
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||
|
||
assert.equal(decision.messages.length, 2)
|
||
const text = messageText(decision.messages[1])
|
||
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||
assert.match(text, /\[dsh-harness\] Роль сессии неясна\./)
|
||
assert.match(text, /четырьмя вариантами/)
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: role-hint stays silent when the step has no new user message', async () => {
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||
assert.deepEqual(decision.messages, [])
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: a user message that equals the role-hint question does not re-trigger role-hint', async () => {
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const user = userMessage(buildRoleHintText(null))
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||
assert.deepEqual(decision.messages, [user], 'isHarnessMessage filters before detectRole')
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: a [claude-harness]-prefixed user message is skipped by role-hint', async () => {
|
||
const outside = tempDir('dsh-harness-outside-')
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false }, outside)
|
||
const user = userMessage('[claude-harness] Роль сессии: dev. Вызови skill role.')
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||
assert.deepEqual(decision.messages, [user], 'legacy prefix is filtered too')
|
||
} finally {
|
||
cleanup(outside)
|
||
}
|
||
})
|
||
|
||
test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not bootstrapOk)', async () => {
|
||
const repo = nonAdoptedRepo()
|
||
try {
|
||
const harness = prestepHarness({ bootstrap: false, roleHint: false }, repo)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||
|
||
assert.equal(decision.messages.length, 1)
|
||
assert.match(messageText(decision.messages[0]), /не подключён к обвязке/)
|
||
} finally {
|
||
cleanup(repo)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Subagent skip (T2.1): real runtime delegation signals
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Agent shapes that the runtime uses for a delegated step. The old
|
||
* parent/parentAgent/parentSessionId/depth checks were dead; these three are
|
||
* the live ones.
|
||
*/
|
||
const SUBAGENT_AGENTS = [
|
||
{ session: { header: { delegationDepth: 1 } } },
|
||
{ session: { header: { parentSession: 's0' } } },
|
||
{ options: { subagentDepth: 1 } },
|
||
]
|
||
|
||
test('apply: a delegated pre-step injects nothing (bootstrap, harness-check, role-hint)', async () => {
|
||
const repo = nonAdoptedRepo()
|
||
const skill = tempDir('dsh-harness-skill-')
|
||
try {
|
||
write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
|
||
const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo)
|
||
|
||
for (const agent of SUBAGENT_AGENTS) {
|
||
const decision = await harness.run({ agent, messages: [userMessage('Напиши тест')] })
|
||
assert.equal(decision.messages.length, 1, `no injection for ${JSON.stringify(agent)}`)
|
||
assert.equal(messageText(decision.messages[0]), 'Напиши тест')
|
||
}
|
||
} finally {
|
||
cleanup(repo)
|
||
cleanup(skill)
|
||
}
|
||
})
|
||
|
||
test('apply: a plain pre-step still injects bootstrap, harness-check, and role-hint', async () => {
|
||
const repo = nonAdoptedRepo()
|
||
const skill = tempDir('dsh-harness-skill-')
|
||
try {
|
||
write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
|
||
const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo)
|
||
const decision = await harness.run({ agent: { session: 's1' }, messages: [userMessage('Напиши тест')] })
|
||
|
||
assert.equal(decision.messages.length, 4)
|
||
assert.equal(messageText(decision.messages[0]), 'Напиши тест')
|
||
const texts = decision.messages.map(messageText)
|
||
assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected')
|
||
assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected')
|
||
assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected')
|
||
} finally {
|
||
cleanup(repo)
|
||
cleanup(skill)
|
||
}
|
||
})
|