feat(dsh): вендор sync-from-upstream — ассертные transform-ы, staging-swap, override dsh-tools.md (#69)

This commit is contained in:
Andrey Epifancev 2026-10-24 23:21:53 +04:00
parent cc0c22f6e0
commit fce88f04d9
7 changed files with 531 additions and 0 deletions

View File

@ -0,0 +1,346 @@
#!/usr/bin/env node
/**
* Sync `skills/` from an obra/superpowers checkout, re-applying this port's
* DSH adaptations.
*
* Vendored from LayneChai/superpowers-dsh `scripts/sync-from-upstream.mjs`
* (MIT), adapted for claude-harness:
*
* - no `package.json` binding: the upstream checkout and the destination
* come from argv (`<upstream-checkout>`, `--out`), not from the package;
* - the DSH-only override is copied from `dsh/bundle/dsh-tools.md` over the
* synced tree as `using-superpowers/references/dsh-tools.md` (resolved
* from `import.meta.url`, never from cwd);
* - the upstream version is recorded in `<out>/.sync-meta.json` together
* with the pinned ref, instead of being compared to package.json;
* - the destination is replaced by a staging tree only after every
* assertion passed, so a broken upstream never leaves a half-synced
* `--out` behind.
*
* The upstream skills are the source of truth for what a skill says. This
* port ships a small, explicit, asserted set of deltas:
*
* 1. `dsh/bundle/dsh-tools.md` replaces the per-harness reference files
* upstream ships for Claude Code, Codex, Pi, Antigravity, Hermes and
* Muse.
* 2. The transforms below rewrite upstream text that names another harness
* and drop the Claude-Code-only subagent stop gate.
* 3. Every remaining `superpowers:` namespace prefix is stripped: the DSH
* skill registry addresses skills by bare name.
*
* A missing anchor fails the run loudly: upstream rewrote text this port
* depends on, so a human must decide what the DSH wording becomes.
*
* Usage:
* node dsh/sync/sync-from-upstream.mjs <upstream-checkout> [--out <dir>] [--ref <tag>]
*
* Defaults: `--out <upstream-checkout>/.sync-out`, `--ref unknown`.
* Staging tree: `<out>.tmp-<pid>`; it is removed on any failure and only
* renamed onto `<out>` after all assertions pass.
*
* @module dsh/sync/sync-from-upstream
*/
import { rmSync } from 'node:fs'
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
/** The single source of the DSH tool mapping: `dsh/sync/..` = `dsh/`. */
const dshToolsOverride = resolve(scriptDir, '..', 'bundle', 'dsh-tools.md')
/** Path of the override inside the synced skill tree. */
const OVERRIDE_TARGET = 'using-superpowers/references/dsh-tools.md'
const UPSTREAM_SKILLS = [
'brainstorming',
'diagnosing-superpowers',
'dispatching-parallel-agents',
'executing-plans',
'finishing-a-development-branch',
'receiving-code-review',
'requesting-code-review',
'subagent-driven-development',
'systematic-debugging',
'test-driven-development',
'using-git-worktrees',
'using-superpowers',
'verification-before-completion',
'writing-plans',
'writing-skills'
]
/** Per-harness reference files upstream v7.0.1 ships that dsh-tools.md replaces. */
const SKIP = [
'using-superpowers/references/antigravity-tools.md',
'using-superpowers/references/claude-code-tools.md',
'using-superpowers/references/codex-tools.md',
'using-superpowers/references/gemini-tools.md',
'using-superpowers/references/hermes-tools.md',
'using-superpowers/references/muse-tools.md',
'using-superpowers/references/pi-tools.md'
]
/** Optional manifest of a reduced checkout (test fixtures); absent upstream. */
const FIXTURE_MANIFEST = '.sync-fixture.json'
/**
* Explicit rewrites applied before the namespace strip. Each entry must match
* exactly `expect` times, or the sync aborts.
*/
const TRANSFORMS = [
{
name: 'using-superpowers: drop the upstream subagent stop gate',
file: 'using-superpowers/SKILL.md',
find: '<SUBAGENT-STOP>\nIf you were dispatched as a subagent to execute a specific task, ignore this skill.\n</SUBAGENT-STOP>\n\n',
replace: ''
},
{
name: 'using-superpowers: DSH platform reference replaces the harness list',
file: 'using-superpowers/SKILL.md',
find: [
'If your harness appears here, read its reference file for special instructions:',
'',
'- Claude Code: `references/claude-code-tools.md`',
'- Codex: `references/codex-tools.md`',
'- Pi: `references/pi-tools.md`',
'- Antigravity: `references/antigravity-tools.md`',
'- Hermes Agent: `references/hermes-tools.md`',
'- Muse: `references/muse-tools.md`'
].join('\n'),
replace: [
'This package is the DeepSeek Harness port. Its platform reference is',
'`references/dsh-tools.md`: the Claude-Code → DSH tool mapping plus the',
'DSH-specific mechanisms these skills rely on (`skill`, `subagent` /',
'`subagent_fork`, `workflow`, `goal`, and the Windows `pwsh` notes).',
'',
'## Invoking skills on DSH',
'',
'Call the `skill` tool with the exact skill name from the catalog before',
'acting on a task that names or clearly matches that skill:',
'',
'- Load applicable skills FIRST, then follow their full instructions.',
'- Skills are addressable by bare name: `brainstorming`, `writing-plans`,',
' `systematic-debugging`, `test-driven-development`, `using-git-worktrees`,',
' `subagent-driven-development`, `dispatching-parallel-agents`, and the rest.',
'- `using-superpowers` itself is discoverable the same way.'
].join('\n')
}
]
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.js', '.cjs', '.mjs', '.ts', '.sh', '.dot', '.html'])
const USAGE = 'usage: node dsh/sync/sync-from-upstream.mjs <upstream-checkout> [--out <dir>] [--ref <tag>]'
/** Staging tree, set once the destination is known. */
let staging = null
function fail(message) {
if (staging) rmSync(staging, { recursive: true, force: true })
console.error(`\n[FAIL] ${message}\n`)
process.exit(1)
}
function usage(message) {
if (message) console.error(`[FAIL] ${message}\n`)
console.error(USAGE)
process.exit(message ? 2 : 0)
}
/** @returns {{upstream: string, out: string, ref: string}} */
function parseArgs(argv) {
let upstream = null
let out = null
let ref = null
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--out' || arg === '--ref') {
const value = argv[++i]
if (!value) usage(`missing value for ${arg}`)
if (arg === '--out') out = value
else ref = value
} else if (arg === '--help' || arg === '-h') {
usage()
} else if (arg.startsWith('--')) {
usage(`unknown option ${arg}`)
} else if (upstream === null) {
upstream = arg
} else {
usage(`unexpected argument ${arg}`)
}
}
if (!upstream) usage('missing <upstream-checkout>')
const upstreamRoot = resolve(upstream)
return {
upstream: upstreamRoot,
out: out === null ? join(upstreamRoot, '.sync-out') : resolve(out),
ref: ref === null ? 'unknown' : ref
}
}
/** @returns {Promise<string[]>} repo-relative file paths under `root`. */
async function walk(root, current = root) {
const out = []
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = join(current, entry.name)
if (entry.isDirectory()) out.push(...(await walk(root, full)))
else out.push(relative(root, full).split(sep).join('/'))
}
return out
}
function isTextFile(rel) {
const dot = rel.lastIndexOf('.')
const slash = rel.lastIndexOf('/')
if (dot <= slash) return true
return TEXT_EXTENSIONS.has(rel.slice(dot))
}
/** Apply every transform whose `file` matches, asserting the anchor exists. */
function applyTransforms(rel, text, applied) {
for (const t of TRANSFORMS) {
if (t.file !== rel) continue
if (t.find === t.replace) continue
const count = text.split(t.find).length - 1
if (count === 0) fail(`anchor missing in ${rel} for "${t.name}"\n find: ${JSON.stringify(t.find.slice(0, 120))}`)
if (count > 1) fail(`anchor matched ${count} times in ${rel} for "${t.name}"`)
text = text.replace(t.find, t.replace)
applied.add(t.name)
}
return text
}
/**
* A real obra/superpowers checkout ships the 15 skills above; the minimal test
* fixture ships two and declares them in `.sync-fixture.json`. The skill-set
* assertion runs in both cases, against the declared set when one exists.
*
* @returns {Promise<{expected: string[], source: string}>}
*/
async function expectedSkillNames(upstreamRoot) {
try {
const manifest = JSON.parse(await readFile(join(upstreamRoot, FIXTURE_MANIFEST), 'utf8'))
if (Array.isArray(manifest.expectedSkills) && manifest.expectedSkills.length > 0) {
return { expected: manifest.expectedSkills, source: FIXTURE_MANIFEST }
}
} catch {
/* real checkouts have no fixture manifest */
}
return { expected: UPSTREAM_SKILLS, source: 'UPSTREAM_SKILLS' }
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const upstreamRoot = args.upstream
const upstreamSkills = join(upstreamRoot, 'skills')
const upstreamStats = await stat(upstreamSkills).catch(() => null)
if (!upstreamStats?.isDirectory()) {
fail(`no skills/ directory in ${upstreamRoot}`)
}
// A new (or removed) upstream skill must be a deliberate decision: it
// changes the skill catalog, the README tables, and possibly the DSH
// adaptations.
const { expected, source } = await expectedSkillNames(upstreamRoot)
const upstreamSkillDirs = (await readdir(upstreamSkills, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
const expectedSorted = [...expected].sort()
if (upstreamSkillDirs.join('\n') !== expectedSorted.join('\n')) {
const added = upstreamSkillDirs.filter((name) => !expectedSorted.includes(name))
const removed = expectedSorted.filter((name) => !upstreamSkillDirs.includes(name))
fail(
`the upstream skill set changed (expected ${expectedSorted.length} skills from ${source}) — decide before syncing:\n` +
(added.length > 0 ? ` added upstream: ${added.join(', ')}\n` : '') +
(removed.length > 0 ? ` removed upstream: ${removed.join(', ')}\n` : '') +
' Update UPSTREAM_SKILLS in this script and the README skill tables.'
)
}
// Report the upstream version so the destination can record it.
let upstreamVersion = 'unknown'
try {
const manifest = JSON.parse(await readFile(join(upstreamRoot, '.claude-plugin', 'plugin.json'), 'utf8'))
upstreamVersion = manifest.version ?? 'unknown'
} catch {
/* version is informational only */
}
const overrideStats = await stat(dshToolsOverride).catch(() => null)
if (!overrideStats?.isFile()) {
fail(`missing DSH override ${dshToolsOverride} (dsh/bundle/dsh-tools.md)`)
}
staging = `${args.out}.tmp-${process.pid}`
await rm(staging, { recursive: true, force: true })
await mkdir(staging, { recursive: true })
const applied = new Set()
let copied = 0
let stripped = 0
for (const rel of await walk(upstreamSkills)) {
if (SKIP.includes(rel)) continue
const sourceFile = join(upstreamSkills, rel)
const target = join(staging, rel)
await mkdir(dirname(target), { recursive: true })
if (!isTextFile(rel)) {
await cp(sourceFile, target)
copied++
continue
}
let text = await readFile(sourceFile, 'utf8')
text = applyTransforms(rel, text, applied)
const namespaced = text.split('superpowers:').length - 1
if (namespaced > 0) {
text = text.replaceAll('superpowers:', '')
stripped += namespaced
}
await writeFile(target, text, 'utf8')
copied++
}
// The DSH-only file replaces upstream text wholesale.
const overrideTarget = join(staging, OVERRIDE_TARGET)
await mkdir(dirname(overrideTarget), { recursive: true })
await cp(dshToolsOverride, overrideTarget)
copied++
// Every transform that names a file must have fired; one that silently
// stopped matching would drop a DSH adaptation from the next release's sync.
const unfired = TRANSFORMS.filter((t) => t.find !== t.replace && !applied.has(t.name))
if (unfired.length > 0) {
fail(`transform(s) never matched a copied file: ${unfired.map((t) => `${t.name} (${t.file})`).join('; ')}`)
}
await writeFile(
join(staging, '.sync-meta.json'),
JSON.stringify({ ref: args.ref, version: upstreamVersion }, null, 2) + '\n',
'utf8'
)
// Everything applied: swap the staging tree in.
await mkdir(dirname(args.out), { recursive: true })
await rm(args.out, { recursive: true, force: true })
await rename(staging, args.out)
staging = null
console.log(`synced ${copied} files from obra/superpowers ${upstreamVersion} (ref ${args.ref}) into ${args.out}`)
console.log(` namespace prefixes stripped: ${stripped}`)
console.log(` transforms applied: ${applied.size}`)
console.log(` dsh override: ${relative(process.cwd(), dshToolsOverride) || dshToolsOverride} -> ${OVERRIDE_TARGET}`)
}
main().catch((error) => {
if (staging) rmSync(staging, { recursive: true, force: true })
console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`)
process.exit(1)
})

View File

@ -0,0 +1,4 @@
{
"name": "superpowers",
"version": "7.0.1"
}

View File

@ -0,0 +1,4 @@
{
"comment": "Reduced upstream checkout for tests. The skill-set assertion still runs, against this declared set instead of the 15 skills of a real obra/superpowers tree.",
"expectedSkills": ["brainstorming", "using-superpowers"]
}

View File

@ -0,0 +1,12 @@
---
name: brainstorming
description: Use when starting any creative work - explores intent before implementation
---
# Brainstorming
Write the design into the plan produced by superpowers:writing-plans, then hand
the plan to superpowers:executing-plans. For bugfixes, pair it with
superpowers:systematic-debugging and superpowers:test-driven-development.
The visual companion lives in `skills/brainstorming/visual-companion.md`.

View File

@ -0,0 +1,46 @@
---
name: using-superpowers
description: Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions
---
<SUBAGENT-STOP>
If you were dispatched as a subagent to execute a specific task, ignore this skill.
</SUBAGENT-STOP>
<EXTREMELY-IMPORTANT>
If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill.
IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.
This is not negotiable. You cannot rationalize your way out of this.
</EXTREMELY-IMPORTANT>
## The Rule
**Invoke relevant or requested skills BEFORE any response or action** — including clarifying questions, exploring the codebase, or checking files. If it turns out wrong for the situation, you don't have to use it.
**Before entering plan mode:** if you haven't already brainstormed, invoke the brainstorming skill first.
Then announce "Using [skill] to [purpose]" and follow the skill exactly. If it has a checklist, create a todo per item.
## Skill Priority
When multiple skills apply, process skills come first — they set the approach, then implementation skills (frontend-design, etc.) carry it out. Brainstorming and systematic-debugging are Superpowers' most common process skills, but the rule holds for any of them.
- "Let's build X" → superpowers:brainstorming first, then implementation skills.
- "Fix this bug" → superpowers:systematic-debugging first, then domain skills.
## Platform Adaptation
If your harness appears here, read its reference file for special instructions:
- Claude Code: `references/claude-code-tools.md`
- Codex: `references/codex-tools.md`
- Pi: `references/pi-tools.md`
- Antigravity: `references/antigravity-tools.md`
- Hermes Agent: `references/hermes-tools.md`
- Muse: `references/muse-tools.md`
## User Instructions
User instructions (CLAUDE.md, AGENTS.md, GEMINI.md, etc, direct requests) take precedence over skills, which in turn override default behavior. Only skip skill workflows or instructions when your human partner has explicitly told you to.

View File

@ -0,0 +1,3 @@
# Claude Code tools
Upstream per-harness reference. The DSH port replaces it with `dsh-tools.md`.

116
dsh/tests/sync.test.mjs Normal file
View File

@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { cleanup, tempDir, write } from './helpers.mjs'
const testDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(testDir, '..', '..')
const script = resolve(repoRoot, 'dsh/sync/sync-from-upstream.mjs')
const fixture = resolve(testDir, 'fixtures', 'superpowers-min')
const bundleDshTools = resolve(repoRoot, 'dsh/bundle/dsh-tools.md')
/** A private copy of the reduced upstream checkout: tests mutate it. */
function fixtureCopy(work) {
const upstream = join(work, 'upstream')
cpSync(fixture, upstream, { recursive: true })
return upstream
}
function runSync(args) {
return spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' })
}
test('sync happy path: transforms, namespace strip, override, meta', () => {
const work = tempDir('dsh-sync-happy-')
try {
const upstream = fixtureCopy(work)
const out = join(work, 'out')
const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1'])
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
assert.match(result.stdout, /synced \d+ files/)
const using = readFileSync(join(out, 'using-superpowers', 'SKILL.md'), 'utf8')
assert.match(using, /references\/dsh-tools\.md/)
assert.doesNotMatch(using, /superpowers:/)
assert.doesNotMatch(using, /<SUBAGENT-STOP>/)
const override = readFileSync(join(out, 'using-superpowers', 'references', 'dsh-tools.md'), 'utf8')
assert.equal(override, readFileSync(bundleDshTools, 'utf8'))
const brainstorming = readFileSync(join(out, 'brainstorming', 'SKILL.md'), 'utf8')
assert.doesNotMatch(brainstorming, /superpowers:/)
assert.match(brainstorming, /writing-plans/)
const meta = JSON.parse(readFileSync(join(out, '.sync-meta.json'), 'utf8'))
assert.deepEqual(meta, { ref: 'v7.0.1', version: '7.0.1' })
assert.equal(existsSync(join(out, 'using-superpowers', 'references', 'claude-code-tools.md')), false)
} finally {
cleanup(work)
}
})
test('sync fails loud on a broken transform anchor and leaves --out untouched', () => {
const work = tempDir('dsh-sync-anchor-')
try {
const upstream = fixtureCopy(work)
const usingPath = join(upstream, 'skills', 'using-superpowers', 'SKILL.md')
const anchor = 'If your harness appears here, read its reference file for special instructions:'
const original = readFileSync(usingPath, 'utf8')
assert.ok(original.includes(anchor), 'fixture must carry the upstream anchor verbatim')
write(usingPath, original.replace(anchor, 'If your harness appears here, read the reference file:'))
const out = join(work, 'out')
write(join(out, 'keep.txt'), 'sentinel\n')
const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1'])
assert.equal(result.status, 1)
assert.match(result.stderr, /DSH platform reference/)
assert.equal(readFileSync(join(out, 'keep.txt'), 'utf8'), 'sentinel\n')
assert.deepEqual(readdirSync(out), ['keep.txt'], 'failed sync must not swap a partial tree in')
assert.deepEqual(
readdirSync(work).filter((name) => name.startsWith('out.tmp-')),
[],
'failed sync must remove its staging tree',
)
} finally {
cleanup(work)
}
})
test('sync fails on a changed skill set', () => {
const work = tempDir('dsh-sync-skill-set-')
try {
const upstream = fixtureCopy(work)
rmSync(join(upstream, 'skills', 'brainstorming'), { recursive: true, force: true })
const result = runSync([upstream, '--out', join(work, 'out')])
assert.equal(result.status, 1)
assert.match(result.stderr, /removed upstream:\s*brainstorming/)
assert.equal(existsSync(join(work, 'out')), false)
} finally {
cleanup(work)
}
})
test('sync fails when the checkout has no skills/ directory', () => {
const work = tempDir('dsh-sync-no-skills-')
try {
const upstream = fixtureCopy(work)
rmSync(join(upstream, 'skills'), { recursive: true, force: true })
const result = runSync([upstream, '--out', join(work, 'out')])
assert.equal(result.status, 1)
assert.match(result.stderr, /no skills\/ directory/)
assert.equal(existsSync(join(work, 'out')), false)
} finally {
cleanup(work)
}
})