This commit is contained in:
parent
622dd32e1a
commit
183f8d8956
@ -21,6 +21,14 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
BUNDLE="$ROOT/dsh/bundle"
|
BUNDLE="$ROOT/dsh/bundle"
|
||||||
|
# O-2: remember whether DSH_HOME came from the environment. Isolating HOME
|
||||||
|
# alone is not enough: an explicit DSH_HOME still writes outside the isolated
|
||||||
|
# tree (a temp HOME with the real DSH_HOME, typically a test or a detached
|
||||||
|
# shell), so the install steps warn about that mixed state.
|
||||||
|
DSH_HOME_FROM_ENV=0
|
||||||
|
if [ -n "${DSH_HOME:-}" ]; then
|
||||||
|
DSH_HOME_FROM_ENV=1
|
||||||
|
fi
|
||||||
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
||||||
DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}"
|
DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}"
|
||||||
DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
|
DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
|
||||||
@ -498,7 +506,21 @@ copy_skills_from() {
|
|||||||
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
|
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# O-2: an explicit DSH_HOME that already exists while $HOME is another tree is
|
||||||
|
# the mixed case: the install writes into the existing DSH_HOME instead of the
|
||||||
|
# isolated one. Warn once per run and continue — the target is valid, only the
|
||||||
|
# HOME/DSH_HOME pair is not isolated together.
|
||||||
|
warn_mixed_dsh_home() {
|
||||||
|
if [ "$DSH_HOME_FROM_ENV" != 1 ]; then return 0; fi
|
||||||
|
if [ "$DSH_HOME" = "$HOME/.dsh" ]; then return 0; fi
|
||||||
|
if [ ! -d "$DSH_HOME" ]; then return 0; fi
|
||||||
|
if [ "${DSH_HOME_WARNED:-0}" = 1 ]; then return 0; fi
|
||||||
|
DSH_HOME_WARNED=1
|
||||||
|
echo "WARN: DSH_HOME=$DSH_HOME points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together" >&2
|
||||||
|
}
|
||||||
|
|
||||||
install_skills() {
|
install_skills() {
|
||||||
|
warn_mixed_dsh_home
|
||||||
mkdir -p "$DSH_HOME/skills"
|
mkdir -p "$DSH_HOME/skills"
|
||||||
|
|
||||||
# Explicit source: sync it directly, no network, no pin enforcement.
|
# Explicit source: sync it directly, no network, no pin enforcement.
|
||||||
@ -576,6 +598,7 @@ install_local_skills() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_roles() {
|
install_roles() {
|
||||||
|
warn_mixed_dsh_home
|
||||||
mkdir -p "$DSH_HOME/roles"
|
mkdir -p "$DSH_HOME/roles"
|
||||||
local src="$ROOT/roles"
|
local src="$ROOT/roles"
|
||||||
local count=0
|
local count=0
|
||||||
|
|||||||
@ -9,7 +9,7 @@ description: Use when executing an implementation plan in DeepSeek Harness (DSH)
|
|||||||
только в DSH-сессиях: у Claude Code синхронный `Task`, у DSH — асинхронный
|
только в DSH-сессиях: у Claude Code синхронный `Task`, у DSH — асинхронный
|
||||||
`subagent`. Это **не паритет** с Claude-SDD и не имитация: протокол построен
|
`subagent`. Это **не паритет** с Claude-SDD и не имитация: протокол построен
|
||||||
на том, что DSH умеет (уведомление о завершении, `send_message`, ledger), а не
|
на том, что DSH умеет (уведомление о завершении, `send_message`, ledger), а не
|
||||||
на том, чего у него нет (возврат результата из вызова инструмента).
|
на том, чего у него нет.
|
||||||
|
|
||||||
## Когда применять
|
## Когда применять
|
||||||
|
|
||||||
@ -62,6 +62,9 @@ overlay ребёнок наследует модель сессии.
|
|||||||
6. Замеченное расхождение с брифом или спекой — это `DONE_WITH_CONCERNS`, даже
|
6. Замеченное расхождение с брифом или спекой — это `DONE_WITH_CONCERNS`, даже
|
||||||
если задача формально выполнена: ревью его проверит, контроллер — решит.
|
если задача формально выполнена: ревью его проверит, контроллер — решит.
|
||||||
|
|
||||||
|
**Порядок TDD.** Шаг «напиши тест и убедись, что он падает (RED)» обязателен
|
||||||
|
ДО кода; реализация без RED — `DONE_WITH_CONCERNS`, ревью это проверяет.
|
||||||
|
|
||||||
## Ожидание без блокировки
|
## Ожидание без блокировки
|
||||||
|
|
||||||
`subagent` возвращает id и сразу завершается; результата в значении
|
`subagent` возвращает id и сразу завершается; результата в значении
|
||||||
@ -135,9 +138,8 @@ ledger лежит в git-ignored scratch (после `git clean -fdx` восст
|
|||||||
фиксирует это наблюдением `O-n`. `model` нельзя передать один, «унаследовав»
|
фиксирует это наблюдением `O-n`. `model` нельзя передать один, «унаследовав»
|
||||||
провайдера: DSH требует `provider` и `model` вместе и на односторонний
|
провайдера: DSH требует `provider` и `model` вместе и на односторонний
|
||||||
маршрут отвечает «child LLM provider and model must be supplied together».
|
маршрут отвечает «child LLM provider and model must be supplied together».
|
||||||
Эта ошибка означает, что в вызове не хватает `provider` (в прогоне #41 так
|
Эта ошибка означает, что в вызове не хватает `provider`, а не сбой валидации:
|
||||||
случилось дважды), а не сбой валидации: проверь аргументы и повтори вызов,
|
проверь аргументы и повтори вызов, передав оба поля.
|
||||||
передав оба поля.
|
|
||||||
|
|
||||||
## Отличие от Claude-SDD
|
## Отличие от Claude-SDD
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
|
|||||||
assert.match(sddText, /name: sdd/)
|
assert.match(sddText, /name: sdd/)
|
||||||
assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch')
|
assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch')
|
||||||
assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file')
|
assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file')
|
||||||
|
// T3.4: TDD order is part of the dispatch protocol: RED before the code.
|
||||||
|
assert.match(sddText, /падает \(RED\)/, 'the sdd skill must require a failing test (RED) before the code')
|
||||||
|
assert.match(sddText, /реализация без RED — `DONE_WITH_CONCERNS`/, 'a missing RED must degrade the report status')
|
||||||
|
|
||||||
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
|
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
|
||||||
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
|
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
|
||||||
@ -638,6 +641,42 @@ test('--global installs DSH role paths and explains the instruction format', ()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => {
|
||||||
|
const work = tempDir()
|
||||||
|
try {
|
||||||
|
const home = join(work, 'home')
|
||||||
|
// An existing DSH_HOME outside the isolated HOME is the mixed state: the
|
||||||
|
// install would write outside the sandbox. The warning must not abort.
|
||||||
|
const sharedDsh = join(work, 'shared-dsh')
|
||||||
|
mkdirSync(home, { recursive: true })
|
||||||
|
mkdirSync(sharedDsh, { recursive: true })
|
||||||
|
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
|
||||||
|
const warn = `WARN: DSH_HOME=${sharedDsh} points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together`
|
||||||
|
|
||||||
|
const skills = runInstaller(home, ['--skills', '--superpowers-from', src], { DSH_HOME: sharedDsh })
|
||||||
|
const skillsOut = skills.stdout + skills.stderr
|
||||||
|
assert.equal(skills.status, 0, skillsOut)
|
||||||
|
assert.ok(skillsOut.includes(warn), skillsOut)
|
||||||
|
assert.ok(
|
||||||
|
existsSync(join(sharedDsh, 'skills', 'using-superpowers', 'SKILL.md')),
|
||||||
|
'the install still runs after the warning',
|
||||||
|
)
|
||||||
|
|
||||||
|
const roles = runInstaller(home, ['--roles'], { DSH_HOME: sharedDsh })
|
||||||
|
const rolesOut = roles.stdout + roles.stderr
|
||||||
|
assert.equal(roles.status, 0, rolesOut)
|
||||||
|
assert.ok(rolesOut.includes(warn), rolesOut)
|
||||||
|
|
||||||
|
// Isolated together ($DSH_HOME=$HOME/.dsh): no warning.
|
||||||
|
const normal = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||||||
|
const normalOut = normal.stdout + normal.stderr
|
||||||
|
assert.equal(normal.status, 0, normalOut)
|
||||||
|
assert.doesNotMatch(normalOut, /WARN: DSH_HOME=/)
|
||||||
|
} finally {
|
||||||
|
cleanup(work)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('a legacy cache without using-superpowers is not a source', () => {
|
test('a legacy cache without using-superpowers is not a source', () => {
|
||||||
const home = tempDir()
|
const home = tempDir()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -19,6 +19,32 @@ const NAMES = [
|
|||||||
'openspec-update-change',
|
'openspec-update-change',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// #62 T2.1/M-2: a negative test must not inherit the host PATH — on Linux a
|
||||||
|
// global npm prefix puts openspec into /usr/bin, so a fixed system PATH is not
|
||||||
|
// openspec-free. The child gets a guard dir holding symlinks to the binaries
|
||||||
|
// the script needs and nothing else. `bash` is addressed by absolute path
|
||||||
|
// because libuv resolves the command with the child's restricted PATH.
|
||||||
|
function which(name) {
|
||||||
|
const out = spawnSync('bash', ['-c', `command -v ${name}`], { encoding: 'utf8' })
|
||||||
|
return out.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASH = which('bash') || '/bin/bash'
|
||||||
|
|
||||||
|
function isolatedPath(home) {
|
||||||
|
const bin = join(home, 'guard-bin')
|
||||||
|
mkdirSync(bin)
|
||||||
|
// The toolchain --check verifies (python3, git, node) plus the POSIX tools
|
||||||
|
// install.sh calls on this path (dirname for ROOT, head for versions, the
|
||||||
|
// ls/sort/tail legacy lookup). openspec is deliberately absent.
|
||||||
|
for (const name of ['python3', 'git', 'node', 'dirname', 'head', 'ls', 'sort', 'tail']) {
|
||||||
|
const target = which(name)
|
||||||
|
assert.ok(target, `cannot resolve ${name}: the isolated PATH would be incomplete`)
|
||||||
|
symlinkSync(target, join(bin, name))
|
||||||
|
}
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
|
||||||
test('repository ships six agents skills for DSH', () => {
|
test('repository ships six agents skills for DSH', () => {
|
||||||
assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents')
|
assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents')
|
||||||
for (const name of NAMES) {
|
for (const name of NAMES) {
|
||||||
@ -49,17 +75,16 @@ test('install.sh --check reports the openspec CLI and the agents skills', () =>
|
|||||||
test('install.sh --check warns but does not fail without the openspec CLI', () => {
|
test('install.sh --check warns but does not fail without the openspec CLI', () => {
|
||||||
const home = tempDir()
|
const home = tempDir()
|
||||||
try {
|
try {
|
||||||
const bin = join(home, 'bin')
|
// #62 T2.1: the guard dir is the whole PATH, so a host openspec CLI (e.g.
|
||||||
mkdirSync(bin, { recursive: true })
|
// /usr/bin on Linux) can never leak into this negative test.
|
||||||
symlinkSync(process.execPath, join(bin, 'node'))
|
const bin = isolatedPath(home)
|
||||||
// openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: home,
|
HOME: home,
|
||||||
DSH_HOME: join(home, '.dsh'),
|
DSH_HOME: join(home, '.dsh'),
|
||||||
PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
PATH: bin,
|
||||||
}
|
}
|
||||||
const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' })
|
const run = spawnSync(BASH, [script, '--check'], { env, encoding: 'utf8' })
|
||||||
assert.equal(run.status, 0, run.stderr)
|
assert.equal(run.status, 0, run.stderr)
|
||||||
assert.match(run.stdout + run.stderr, /WARN: openspec not found/)
|
assert.match(run.stdout + run.stderr, /WARN: openspec not found/)
|
||||||
} finally {
|
} finally {
|
||||||
@ -134,17 +159,15 @@ test('--openspec fails without the openspec CLI', () => {
|
|||||||
try {
|
try {
|
||||||
const project = join(home, 'project')
|
const project = join(home, 'project')
|
||||||
mkdirSync(join(project, 'openspec'), { recursive: true })
|
mkdirSync(join(project, 'openspec'), { recursive: true })
|
||||||
const bin = join(home, 'bin')
|
// #62 T2.1: same guard-dir isolation as the --check negative test.
|
||||||
mkdirSync(bin, { recursive: true })
|
const bin = isolatedPath(home)
|
||||||
symlinkSync(process.execPath, join(bin, 'node'))
|
|
||||||
// openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: home,
|
HOME: home,
|
||||||
DSH_HOME: join(home, '.dsh'),
|
DSH_HOME: join(home, '.dsh'),
|
||||||
PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
PATH: bin,
|
||||||
}
|
}
|
||||||
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
|
const run = spawnSync(BASH, [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
|
||||||
assert.notEqual(run.status, 0)
|
assert.notEqual(run.status, 0)
|
||||||
assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/)
|
assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user