claude-harness/dsh/bundle/lib/bootstrap.js

224 lines
7.9 KiB
JavaScript

/**
* Superpowers bootstrap for DeepSeek Harness.
*
* Claude Code delivers `using-superpowers` through a SessionStart hook. DSH has
* no shell hook layer, so this module builds the same bootstrap text and the
* plugin injects it once per session from `agent/pre-step`. The marker at the
* top makes the injection idempotent and lets a later step re-inject after
* compaction (the old message is no longer in `session.surface.nodes`).
*/
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { isLegacyClaudePath, warnLegacyOnce } from './legacy.js'
const DEFAULT_MAPPING = [
'Use the tools reported by the harness. In particular:',
'- read/write/edit/bash for files and commands;',
'- skill({ name }) to load a skill (no `superpowers:` namespace);',
'- todo_write for task tracking;',
'- subagent for asynchronous delegation, or workflow for synchronous fan-out.',
].join('\n')
const cache = new Map()
/** Remove a leading YAML frontmatter block, if present. */
export function stripFrontmatter(text) {
if (typeof text !== 'string') return ''
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
}
/** Truncate to a UTF-8 byte budget, keeping a visible notice. */
export function truncateUtf8(text, maxBytes) {
const limit = Math.max(1024, Number(maxBytes) || 65_536)
if (Buffer.byteLength(text, 'utf8') <= limit) return text
const suffix = '\n\n[bootstrap truncated by dsh-harness maxBytes]\n'
const head = Buffer.from(text, 'utf8')
.subarray(0, Math.max(0, limit - Buffer.byteLength(suffix, 'utf8')))
.toString('utf8')
.replace(/\uFFFD+$/u, '')
return head + suffix
}
/** Sort dotted version directory names without a semver dependency. */
export function compareVersions(a, b) {
const pa = String(a).split(/[.-]/)
const pb = String(b).split(/[.-]/)
const n = Math.max(pa.length, pb.length)
for (let i = 0; i < n; i += 1) {
const x = pa[i] ?? ''
const y = pb[i] ?? ''
const nx = Number(x)
const ny = Number(y)
if (Number.isFinite(nx) && Number.isFinite(ny) && nx !== ny) return nx - ny
const c = String(x).localeCompare(String(y), undefined, { numeric: true })
if (c !== 0) return c
}
return 0
}
/** Candidate `using-superpowers/SKILL.md` paths, most specific first. */
export function superpowersCandidates({ home = homedir(), env = process.env } = {}) {
const out = []
if (env.DSH_SUPERPOWERS_SKILL) out.push(env.DSH_SUPERPOWERS_SKILL)
// DSH-owned copies come first: `dsh/install.sh --skills` copies the skill
// bodies into ~/.dsh/skills, so a Claude Code rollback cannot break the
// bootstrap.
out.push(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))
out.push(join(home, '.agents', 'skills', 'using-superpowers', 'SKILL.md'))
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers')
if (existsSync(root)) {
let entries = []
try {
entries = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareVersions)
.reverse()
} catch {
entries = []
}
for (const version of entries) {
out.push(join(root, version, 'skills', 'using-superpowers', 'SKILL.md'))
}
}
out.push(join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md'))
return out
}
/** Resolve the skill file: explicit config, env override, DSH-owned copy, then Claude Code fallbacks. */
export function resolveBootstrapSkill({ configuredPath = '', env = process.env, home = homedir() } = {}) {
if (configuredPath && existsSync(configuredPath)) return configuredPath
return superpowersCandidates({ home, env }).find((path) => existsSync(path))
}
/** Build the exact text injected before the first model step. */
export function buildBootstrapText({ skillBody, mapping, marker }) {
return [
marker,
'<EXTREMELY_IMPORTANT>',
'You have superpowers.',
'',
"**Below is the full content of your 'using-superpowers' skill — your introduction to using skills. On this harness, load other skills with the `skill` tool. There is no `superpowers:` namespace; use the skill name, for example `brainstorming`.**",
'',
skillBody,
'',
'## DSH tool mapping',
'',
mapping,
'</EXTREMELY_IMPORTANT>',
'',
].join('\n')
}
function statKey(path) {
try {
const stat = statSync(path)
return `${stat.mtimeMs}:${stat.size}`
} catch {
return ''
}
}
/**
* Build (or reuse) the bootstrap payload.
* @returns {{path: string, marker: string, text: string}|undefined}
*/
export function prepareBootstrap({
skillPath = '',
mappingPath = '',
maxBytes = 65_536,
logger,
env = process.env,
home = homedir(),
} = {}) {
const resolved = resolveBootstrapSkill({ configuredPath: skillPath, env, home })
if (!resolved) {
logger?.warn?.('[dsh-harness] superpowers bootstrap skill not found; skipping injection')
return undefined
}
if (isLegacyClaudePath(resolved, home)) {
warnLegacyOnce(logger, 'bootstrap', resolved, 'skills')
}
const mappingFile = mappingPath && existsSync(mappingPath) ? mappingPath : ''
const key = `${resolved}|${mappingFile}|${maxBytes}`
const fingerprint = `${statKey(resolved)}|${mappingFile ? statKey(mappingFile) : 'default'}`
const hit = cache.get(key)
if (hit && hit.fingerprint === fingerprint) return hit.value
let skillText
try {
skillText = readFileSync(resolved, 'utf8')
} catch (error) {
logger?.warn?.('[dsh-harness] cannot read superpowers bootstrap %s: %s', resolved, error?.message ?? error)
return undefined
}
const skillBody = stripFrontmatter(skillText)
if (!skillBody) {
logger?.warn?.('[dsh-harness] superpowers bootstrap is empty: %s', resolved)
return undefined
}
let mapping = DEFAULT_MAPPING
if (mappingFile) {
try {
mapping = readFileSync(mappingFile, 'utf8').trim() || DEFAULT_MAPPING
} catch {
mapping = DEFAULT_MAPPING
}
}
const hash = createHash('sha256').update(skillBody).update('\0').update(mapping).digest('hex').slice(0, 16)
const marker = `<!-- DSH-HARNESS-BOOTSTRAP:${hash} -->`
const text = truncateUtf8(buildBootstrapText({ skillBody, mapping, marker }), maxBytes)
const value = { path: resolved, marker, text }
cache.set(key, { fingerprint, value })
return value
}
/** Read model-visible text from a session message or a session event payload. */
export function textOf(value) {
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map(textOf).join('\n')
if (!value || typeof value !== 'object') return ''
if (Array.isArray(value.content)) return value.content.map(textOf).join('\n')
if (typeof value.content === 'string') return value.content
if (typeof value.text === 'string') return value.text
return ''
}
/**
* Whether this exact injection marker is still visible in the session.
* Messages under consideration by the current pre-step are checked first;
* then the durable visible surface, which drops a message after compaction.
*/
export function isInjectionVisible(agent, messages, marker) {
if (!marker) return false
if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
const session = agent?.session
if (!session || typeof session.eventAt !== 'function') return false
const nodes = session.surface?.nodes
if (!nodes) return false
for (const seq of new Set(nodes)) {
try {
const event = session.eventAt(seq)
if (event?.type === 'user/message' && textOf(event.data).includes(marker)) return true
} catch {
// A seq outside the current surface is not evidence of visibility.
}
}
return false
}
/** Back-compat name for the pre-refactor bootstrap visibility check. */
export const isBootstrapVisible = isInjectionVisible