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

503 lines
21 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. The one
* exception is `optional: true` — a transform whose upstream text v7.0.1 has
* already dropped: it is reported as a warning, not a failure, so the port
* keeps the transform ready for the release that brings the text back.
*
* 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, isAbsolute, 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'
]
const DSH_HOME = '$DSH_HOME'
/**
* Explicit rewrites applied before the namespace strip. Each entry must match
* exactly `expect` times, or the sync aborts — unless it is `optional: true`,
* in which case an absent anchor is reported as a warning. Optional entries
* exist for upstream deletions: v7.0.1 dropped the subagent hook example, but
* an upstream release may bring it back, and the adaptation must then return
* automatically instead of being silently absent from the transform list.
*/
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')
},
{
name: 'brainstorming: DSH platform note for the visual companion',
file: 'brainstorming/visual-companion.md',
find: 'Browser-based visual brainstorming companion for showing mockups, diagrams, and options.\n',
replace: [
'Browser-based visual brainstorming companion for showing mockups, diagrams, and options.',
'',
'## Platform notes (DeepSeek Harness port)',
'',
'The companion server is `scripts/server.cjs` (plain Node.js, runs anywhere',
'Node runs, including Windows). The `start-server.sh` / `stop-server.sh`',
'helpers are bash-only: on Windows, start the server directly with',
'`node scripts/server.cjs --project-dir <path> --open` and stop it by killing',
'the process. All other guidance below is platform-neutral.',
''
].join('\n')
},
{
name: 'executing-plans: DSH availability of subagents',
file: 'executing-plans/SKILL.md',
find: [
'- You have a plan from superpowers:writing-plans and your human partner',
' chose inline execution at the handoff.',
'- Your harness has no subagent tool (see the per-platform references in',
' `../using-superpowers/references/`). Never fabricate a dispatch; run',
' the plan here.'
].join('\n'),
replace: [
'- You have a plan from writing-plans and your human partner chose inline',
' (Native) execution at the handoff.',
'- No subagent tool is available in this session, or your human partner',
' chose Native. On the DeepSeek Harness `subagent` / `subagent_fork`',
' normally ARE available (see `../using-superpowers/references/dsh-tools.md`),',
' so prefer subagent-driven-development; run the plan here when the work',
' must stay in one context. Never fabricate a dispatch.'
].join('\n')
},
{
name: 'executing-plans: invoke sdd-workspace through bash',
file: 'executing-plans/SKILL.md',
find: '`../subagent-driven-development/scripts/sdd-workspace PLAN_FILE`',
replace: '`bash ../subagent-driven-development/scripts/sdd-workspace PLAN_FILE`'
},
{
name: 'executing-plans: invoke task-start through bash',
file: 'executing-plans/SKILL.md',
find: "Run this skill's `scripts/task-start PLAN_FILE N`.",
replace: "Run this skill's `bash scripts/task-start PLAN_FILE N`."
},
{
name: 'executing-plans: invoke task-done through bash',
file: 'executing-plans/SKILL.md',
find: "Run this skill's `scripts/task-done PLAN_FILE N BASE -- <test command>`",
replace: "Run this skill's `bash scripts/task-done PLAN_FILE N BASE -- <test command>`"
},
{
name: 'executing-plans: invoke review-package through bash',
file: 'executing-plans/SKILL.md',
find: 'Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`',
replace: 'Run `bash ../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`'
},
{
name: 'executing-plans: task-start calls task-brief through bash',
file: 'executing-plans/scripts/task-start',
find: 'out=$("$sdd/task-brief" "$plan" "$n")',
replace: 'out=$("${BASH:-bash}" "$sdd/task-brief" "$plan" "$n")'
},
{
name: 'executing-plans: task-done calls sdd-workspace through bash',
file: 'executing-plans/scripts/task-done',
find: 'dir=$("$sdd/sdd-workspace" "$plan")',
replace: 'dir=$("${BASH:-bash}" "$sdd/sdd-workspace" "$plan")'
},
{
name: 'writing-skills: DSH skills directory replaces the cross-harness paths',
file: 'writing-skills/SKILL.md',
find: "**Personal skills live in your runtime's skills directory** (`~/.claude/skills/` on Claude Code) — see [codex-tools.md](../using-superpowers/references/codex-tools.md) or [gemini-tools.md](../using-superpowers/references/gemini-tools.md) for the path on those runtimes. Codex, Copilot CLI, and Gemini CLI all also recognize `~/.agents/skills/` as a cross-runtime alias.",
replace: "**Personal skills live in your runtime's skills directory**: on the DeepSeek Harness, that is `" +
DSH_HOME + "/skills/` (user level, discovered automatically by `skill-filesystem`), the project `.dsh/skills/` / " +
'`.agents/skills/` roots, or — for packaged skills — the `skills/` directory inside a plugin bundle like this ' +
'one. See [dsh-tools.md](../using-superpowers/references/dsh-tools.md) for the full mechanism.'
},
{
// optional: upstream v7.0.1 dropped the hook example this transform rewrites.
name: 'subagent-driven-development: hook example uses the DSH home',
file: 'subagent-driven-development/SKILL.md',
find: 'You: "User level (~/.config/superpowers/hooks/)"',
replace: 'You: "Install the hook at the user level (' + DSH_HOME + ')"',
optional: true
},
{
name: 'diagnosing-superpowers: case workspace lives under the DSH home',
file: 'diagnosing-superpowers/SKILL.md',
find: '`~/.superpowers/diagnosing-superpowers/<session-id>/`',
replace: '`' + DSH_HOME + '/diagnosing-superpowers/<session-id>/`'
},
{
name: 'diagnosing-superpowers: case template uses the DSH home',
file: 'diagnosing-superpowers/templates/case.md',
find: 'Workspace: ~/.superpowers/diagnosing-superpowers/<session-id>/',
replace: 'Workspace: ' + DSH_HOME + '/diagnosing-superpowers/<session-id>/'
},
{
name: 'diagnosing-superpowers: report template uses the DSH home',
file: 'diagnosing-superpowers/templates/report.md',
find: 'Report path: ~/.superpowers/diagnosing-superpowers/<session-id>/report.md',
replace: 'Report path: ' + DSH_HOME + '/diagnosing-superpowers/<session-id>/report.md'
},
{
name: 'diagnosing-superpowers: DSH session discovery facts',
file: 'diagnosing-superpowers/references/session-discovery.md',
find: 'limitation and ask for the missing path, export, or identifying detail.',
replace: [
'limitation and ask for the missing path, export, or identifying detail.',
'',
'## DeepSeek Harness',
'',
'Verified in this environment; confirm it against the records you actually read.',
'',
'- Transcripts: `' + DSH_HOME + '/sessions/<slugified-cwd>/session-<uuid>/session*.jsonl.zstd`',
' (`' + DSH_HOME + '` defaults to `~/.dsh`). The slug is the working directory with',
' separators and non-alphanumeric characters folded to `-`, wrapped in `--`.',
'- Child sessions — work dispatched through `subagent` / `subagent_fork` — are',
' peer directories under the same slug whose header record carries',
' `delegationDepth: 1`.',
'- The schema version is in the filename (`session.v4.jsonl.zstd`; older files',
' are `session.v3.jsonl.zstd` or `session.jsonl.zstd`). Read the header',
" record's `version` instead of guessing from the name.",
'- Each file is a concatenation of independent zstd frames, and the `zstd` CLI',
' is usually absent. See `../using-superpowers/references/dsh-tools.md` for the',
' frame-by-frame Node decompression command.',
'- Records are JSON lines carrying `type`, `seq`, `time`, and `data`. Establish',
' what each type means from the records themselves — never import a meaning',
' from another harness.'
].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
}
}
/**
* True when `parent` is `child` or one of its ancestors (`/a` → `/a/b`).
*
* A parent hop is exactly `..` or `..<sep>…`; a sibling named `..foo` also makes
* `relative()` start with `..` but is a descendant, so the collision guard must
* still fire for it (T1.9).
*/
function isAncestorOrSame(parent, child) {
const rel = relative(parent, child)
if (rel === '') return true
if (rel === '..' || rel.startsWith('..' + sep)) return false
return !isAbsolute(rel)
}
/** @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.
* Optional transforms whose anchor is gone are collected for a warning.
*/
function applyTransforms(rel, text, applied, optionalMisses) {
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) {
if (t.optional) {
optionalMisses.push(t)
continue
}
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
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const upstreamRoot = args.upstream
const upstreamSkills = join(upstreamRoot, 'skills')
// The destination must never overlap the source: swapping it in would
// delete the checkout we read from, and `--out <checkout>/skills` would
// delete the very tree being walked. Refuse before any write or delete.
if (isAncestorOrSame(args.out, upstreamRoot) || args.out === upstreamSkills) {
fail(`--out collides with the upstream checkout (${args.out})`)
}
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 upstreamSkillDirs = (await readdir(upstreamSkills, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
const expectedSorted = [...UPSTREAM_SKILLS].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 UPSTREAM_SKILLS) — 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()
const optionalMisses = []
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, optionalMisses)
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 required transform 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 && !t.optional && !applied.has(t.name))
if (unfired.length > 0) {
fail(`transform(s) never matched a copied file: ${unfired.map((t) => `${t.name} (${t.file})`).join('; ')}`)
}
for (const t of optionalMisses) {
console.warn(`[warn] optional transform did not match, anchor gone upstream: "${t.name}" (${t.file})`)
}
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)
})