claude-harness/dsh/sync/sync-from-upstream.mjs

347 lines
13 KiB
JavaScript

#!/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)
})