From bbebe4e56d15f9ae9aafcc12c37f4eafef1277d3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 01:04:03 +0000 Subject: [PATCH] wip: harness registration in setup, uninstall, tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- README.md | 4 +- bin/gstack-harness | 48 ++++-- bin/gstack-harness-ledger.sh | 5 +- bin/gstack-install-registry.sh | 4 +- bin/gstack-uninstall | 16 ++ hosts/hermes.ts | 8 +- hosts/openclaw.ts | 9 +- lib/harness-register.ts | 71 ++++++--- scripts/host-config.ts | 9 ++ setup | 95 ++++++------ test/harness-register.test.ts | 260 +++++++++++++++++++++++++++++++++ 11 files changed, 425 insertions(+), 104 deletions(-) create mode 100644 test/harness-register.test.ts diff --git a/README.md b/README.md index 2e8ca7d25..e1a74eae0 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ installs and passes the conformance tests but has no certification run yet; | GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` | | Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` | | Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` | -| OpenClaw | instruction-only | `--host openclaw` (prints the digest path; ACP spawns Claude Code — [docs/OPENCLAW.md](docs/OPENCLAW.md)) | "Load gstack. Run /review" | advisory, not blocked | — | re-copy the digest after upgrades | -| Hermes | instruction-only | `--host hermes` (prints the digest path and `gen:skill-docs --host hermes`) | copy the digest, or render skills yourself | advisory, not blocked | — | re-copy the digest after upgrades | +| OpenClaw | experimental | `--host openclaw` (one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the `gstack` skill | advisory, not blocked | — | `./setup --host openclaw` | +| Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` | | GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — | Per-host notes (Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). diff --git a/bin/gstack-harness b/bin/gstack-harness index 8a57ee272..2f4f2a03b 100755 --- a/bin/gstack-harness +++ b/bin/gstack-harness @@ -36,13 +36,16 @@ const env = process.env; const home = os.homedir(); const self = path.join(checkout, 'bin', 'gstack-harness'); -function receipt(r: RegisterResult): void { +function receiptLines(r: RegisterResult): string[] { const t = TARGETS[r.target]; - console.log('gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')'); - if (r.router) console.log(' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')); - console.log(' ledger: ' + r.ledger); - console.log(' remove: ' + self + ' unregister ' + r.id); + return [ + 'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')', + ...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []), + ' ledger: ' + r.ledger, + ' remove: ' + self + ' unregister ' + r.id, + ]; } +const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n')); function usage(code: number): never { const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n'); @@ -74,28 +77,37 @@ async function main(): Promise { return 0; } if (t.mechanism === 'verify-only') { - const seen = harnessSees(t, '', env); + const seen = harnessSees(t, '', env, ['review', 'gstack-review'], true); if (seen.visible === 'yes') { console.log('gstack-harness: ' + t.displayName + ': discovered gstack skills through ~/.claude/skills (Claude-rendered, unverified in a live session); checked: ' + seen.checked); return 0; } console.log('gstack-harness: ' + t.displayName + ': unverified (' + (seen.checked ? seen.checked + ' did not list gstack' : t.name + ' is not on PATH') + '). Install gstack for Claude Code (./setup), or paste the block from: ' + self + ' register generic --print'); return 3; } - receipt(register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry })); + const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry }); + if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') })); + else receipt(r); return 0; } case 'unregister': { const remote = bool('--remote'); const name = argv.shift() ?? ''; - const ids = listLedgers(stateRoot).filter(l => l.ledger.meta.id === name || l.ledger.meta.target === name); - if (remote) for (const l of ids.filter(l => l.ledger.meta.target === 'qm')) console.log('gstack-harness: archived QM pack ' + await removeQmPack(stateRoot, env, l.ledger.meta.id)); - for (const id of unregister(stateRoot, checkout, name, env)) console.log('gstack-harness: removed ' + id); + const matches = listLedgers(stateRoot).filter(l => l.ledger.meta.id === name || l.ledger.meta.target === name); + if (!matches.length) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no registration named ' + JSON.stringify(name)); + for (const l of matches) { + if (remote && l.ledger.meta.target === 'qm') { + console.log('gstack-harness: archived QM pack ' + await removeQmPack(stateRoot, env, l.ledger.meta.id)); + fs.rmSync(l.file); + console.log('gstack-harness: removed ' + l.ledger.meta.id); + } else for (const id of unregister(stateRoot, checkout, l.ledger.meta.id, env)) console.log('gstack-harness: removed ' + id); + } return 0; } case 'status': { - const rows = status(stateRoot, version, env, !bool('--no-check')); - const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...harnessSees(TARGETS[n], '', env) })); + const check = !bool('--no-check'); + const rows = status(stateRoot, version, env, check); + const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) })); if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; } if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine'); - for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + '\t' + r.id); + for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id); for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : '')); return 0; } @@ -105,14 +117,18 @@ async function main(): Promise { } case 'auto': { const d = detect(env, home); + let failed = 0; + const done = new Set(); for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) { - try { receipt(register({ ...base, target: TARGETS[n], registry: true })); } - catch (e) { if (e instanceof HarnessError) console.error(e.line()); else throw e; } + try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); } + catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; } } + try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); } + catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; } for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) { console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n); } - return 0; + return failed; } case 'manifest': { const t = getTarget(argv.shift() ?? 'generic'); diff --git a/bin/gstack-harness-ledger.sh b/bin/gstack-harness-ledger.sh index e114a1862..73d0e621f 100644 --- a/bin/gstack-harness-ledger.sh +++ b/bin/gstack-harness-ledger.sh @@ -28,13 +28,13 @@ _gstack_harness_cut_block() { } gstack_harness_unregister_ledger() { - local ledger="$1" root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" stamp kind a b target="" dest="" backup + local ledger="$1" root="${GSTACK_STATE_ROOT:-$HOME/.gstack}" stamp kind a b target="" dest="" pack="" backup [ -f "$ledger" ] || return 0 stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)-$$" backup="$root/backups/harness/$stamp" while IFS=$'\t' read -r kind a b; do case "$kind" in - meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; esac ;; + meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;; file) { [ -f "$b" ] && [ ! -L "$b" ]; } || continue if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b" @@ -48,6 +48,7 @@ gstack_harness_unregister_ledger() { case "$target" in openclaw|hermes) if command -v gstack_install_registry_remove >/dev/null 2>&1; then gstack_install_registry_remove "$target" "$dest" || true; fi ;; + qm) [ -z "$pack" ] || echo " QM: dropped the local record of pack $pack; the pack stays in your QM org until an org admin archives it (DELETE $dest/v1/admin/skill-packs/$pack)" ;; esac rm -f "$ledger" } diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 44bed372a..5f1ba1495 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -182,8 +182,8 @@ gstack_install_render_for() { gstack_host_tier() { case "$1" in claude) echo full ;; - codex|kiro|factory|opencode|cursor|copilot|agy) echo experimental ;; - slate|openclaw|hermes|gbrain) echo instruction-only ;; + codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;; + slate|gbrain) echo instruction-only ;; *) echo unknown ;; esac } diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 3fa675e4c..5bbc12222 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -24,6 +24,10 @@ # ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks # ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks # ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks +# harness registrations — every file gstack-harness wrote into OpenClaw, +# Hermes, a Dot or another harness (from its +# ledger; edited files move to a backup), and +# its gstack:begin/end context block # installs.tsv rows — install registry rows for removed installs # ~/.gstack/ — global state (config, analytics, sessions, projects, # repos, installation-id, browse error logs) @@ -257,6 +261,18 @@ if [ -x "$GSTACK_CONFIG" ] && [ "$("$GSTACK_CONFIG" get memorable_recall 2>/dev/ fi fi +# ─── Remove harness registrations (bin/gstack-harness) ────── +# Each ledger names exactly the files gstack-harness wrote into a harness's own +# skills folder; read before the checkout and the state root go. A QM org pack +# is left alone: `gstack-harness unregister qm --remote` archives it. +if [ -d "$RESOLVED_ROOT/harness" ] && [ -f "$(dirname "$0")/gstack-harness-ledger.sh" ]; then + while IFS= read -r _HARNESS_LINE; do + case "$_HARNESS_LINE" in harness:*) REMOVED+=("$_HARNESS_LINE") ;; ?*) echo "$_HARNESS_LINE" ;; esac + done <', diff --git a/hosts/openclaw.ts b/hosts/openclaw.ts index ba806c88d..1b1c431e5 100644 --- a/hosts/openclaw.ts +++ b/hosts/openclaw.ts @@ -3,7 +3,7 @@ import { defineHost, CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_RE const openclaw = defineHost({ name: 'openclaw', displayName: 'OpenClaw', - tier: 'instruction-only', + tier: 'experimental', capabilities: { toolExecution: true, questions: 'prose', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' }, extraPathRewrites: [ @@ -14,11 +14,12 @@ const openclaw = defineHost({ // Suppress Claude-specific preamble sections that don't apply to OpenClaw suppressedResolvers: [...CROSS_MODEL_RESOLVERS, ...GBRAIN_RESOLVERS], - // No full install arm — users can hand-copy the instruction-only digest - // (setup's explainer arm prints this path; never auto-copied). + // ./setup --host openclaw (and setup run inside OpenClaw) registers through + // gstack-harness: one gstack router skill in $OPENCLAW_STATE_DIR/skills, or + // every skill with --skills all. OpenClaw's ACP route (docs/OPENCLAW.md) stays. install: { linkingStrategy: 'symlink-generated', - instructionTier: { rulesFile: 'agents-digest/gstack-AGENTS.md' }, + registration: 'harness', }, coAuthorTrailer: 'Co-Authored-By: OpenClaw Agent ', diff --git a/lib/harness-register.ts b/lib/harness-register.ts index 36c26dddd..5a11e3bac 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -30,6 +30,8 @@ export const ROUTER_DESCRIPTION_MAX = 300; export const CONTEXT_BLOCK_MAX = 600; export const BLOCK_BEGIN = ''; export const BLOCK_END = ''; +/** Where a Dot keeps its own skills (feder-cr/dots nanobot/dots/skills.py DOT_SKILLS_DIR). */ +export const DOT_SKILLS_DIR = '/home/dot/skills'; export const ROUTER_DESCRIPTION = 'gstack engineering workflows: code review, QA testing, debugging, security audit, plan and design review, shipping PRs, release docs, retros. Load first for any coding, review, test, plan or release task; it lists every gstack skill and where to read it.'; export interface Target { @@ -46,8 +48,8 @@ export interface Target { inside: (env: Env, ancestry: string[]) => boolean; /** Installed on this machine (binary on PATH or its home folder). */ present: (env: Env, home: string) => boolean; - /** The harness's own command that lists the skills it sees, when it has one. */ - listCommand?: (dest: string) => { argv: string[]; env: Env; expect: RegExp }; + /** The harness's own command that lists the skills it sees, and how one skill name shows in its output. */ + listCommand?: (dest: string) => { argv: string[]; env: Env; listed: (name: string) => RegExp }; } const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin))); @@ -61,20 +63,20 @@ export const TARGETS: Record = { openclaw: { name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'router', modes: ['router', 'all'], dest: (env, home) => path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'skills'), - inside: (env, anc) => !!env.OPENCLAW_SESSION || ancestorIs(anc, 'openclaw'), + inside: (env, anc) => env.OPENCLAW_SHELL === 'exec' || ancestorIs(anc, 'openclaw'), present: (env, home) => onPath('openclaw', env) || fs.existsSync(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw')), - listCommand: dest => ({ argv: ['openclaw', 'skills', 'list', '--json'], env: { OPENCLAW_STATE_DIR: path.dirname(dest) }, expect: /"name":\s*"gstack(-[a-z0-9-]+)?"/ }), + listCommand: dest => ({ argv: ['openclaw', 'skills', 'list', '--json'], env: { OPENCLAW_STATE_DIR: path.dirname(dest) }, listed: name => new RegExp('"name":\\s*"' + name + '"') }), }, hermes: { name: 'hermes', displayName: 'Hermes', mechanism: 'skill-folder', render: 'host', defaultMode: 'all', modes: ['all'], dest: (env, home) => path.join(env.HERMES_HOME || path.join(home, '.hermes'), 'skills', 'gstack'), - inside: (env, anc) => !!env.HERMES_SESSION_KEY || ancestorIs(anc, 'hermes'), + inside: (env, anc) => env.HERMES_AGENT === 'true' || env.AI_AGENT === 'hermes-agent' || ancestorIs(anc, 'hermes'), present: (env, home) => onPath('hermes', env) || fs.existsSync(env.HERMES_HOME || path.join(home, '.hermes')), - listCommand: dest => ({ argv: ['hermes', 'skills', 'list'], env: { HERMES_HOME: path.dirname(path.dirname(dest)) }, expect: /gstack-[a-z0-9-]+/ }), + listCommand: dest => ({ argv: ['hermes', 'skills', 'list'], env: { HERMES_HOME: path.dirname(path.dirname(dest)) }, listed: name => new RegExp('\\s' + name + '\\s') }), }, dots: { name: 'dots', displayName: 'dots (feder-cr)', mechanism: 'skill-folder', render: 'claude', defaultMode: 'router', modes: ['router', 'all'], - dest: (_env, home) => path.join(home, 'skills'), + dest: () => DOT_SKILLS_DIR, inside: (env) => fs.existsSync('/opt/invisible-dots') && (env.HOME ?? '') === '/home/dot', present: () => fs.existsSync('/opt/invisible-dots'), }, @@ -93,12 +95,12 @@ export const TARGETS: Record = { muse: { name: 'muse', displayName: 'Muse Code', mechanism: 'verify-only', render: null, defaultMode: 'all', modes: ['all'], dest: () => null, inside: () => false, present: env => onPath('muse', env), - listCommand: () => ({ argv: ['muse', 'skills', 'list'], env: {}, expect: /\b(gstack|review|ship|office-hours)\b/ }), + listCommand: () => ({ argv: ['muse', 'skills', 'list'], env: {}, listed: name => new RegExp('\\b' + name + '\\b') }), }, grok: { name: 'grok', displayName: 'Grok Build', mechanism: 'verify-only', render: null, defaultMode: 'all', modes: ['all'], dest: () => null, inside: () => false, present: env => onPath('grok', env), - listCommand: () => ({ argv: ['grok', 'inspect'], env: {}, expect: /\b(gstack|review|ship|office-hours)\b/ }), + listCommand: () => ({ argv: ['grok', 'inspect'], env: {}, listed: name => new RegExp('\\b' + name + '\\b') }), }, }; @@ -186,6 +188,14 @@ export function oneLineUse(description: string): string { return s.length > 160 ? s.slice(0, 157).trimEnd() + '...' : s; } +/** Agent Skills frontmatter in its strictest reading (dots): single-line name equal to the folder, single-line description of at most 1024 characters. */ +export function flatFrontmatter(md: string, name: string): string { + const end = md.startsWith('---\n') ? md.indexOf('\n---', 4) : -1; + const desc = frontmatterField(md, 'description').replace(/\s+/g, ' ').trim(); + const body = end < 0 ? md : md.slice(md.indexOf('\n', end + 4) + 1); + return '---\nname: ' + name + '\ndescription: ' + (desc.length > 1024 ? desc.slice(0, 1021).trimEnd() + '...' : desc) + '\n---\n' + body; +} + function skillDirs(root: string): SkillEntry[] { if (!fs.existsSync(root)) return []; return fs.readdirSync(root).sort().flatMap(name => { @@ -324,13 +334,13 @@ function resolveDest(opts: RegisterOptions, id?: string): string | null { } /** Desired files (absolute path -> bytes) and the router path, for a skill-folder or generic target. */ -export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map; router: string | null; skills: number } { +export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map; router: string | null; names: string[] } { const t = opts.target; const skills = sourceSkills(t, opts.checkout, scratch); const files = new Map(); const copyDir = (from: string, to: string) => { for (const f of walkFiles(from)) files.set(path.join(to, path.relative(from, f)), fs.readFileSync(f)); }; const claudeFiles = (s: SkillEntry, to: string) => { - files.set(path.join(to, 'SKILL.md'), fs.readFileSync(path.join(s.dir, 'SKILL.md'))); + files.set(path.join(to, 'SKILL.md'), Buffer.from(flatFrontmatter(fs.readFileSync(path.join(s.dir, 'SKILL.md'), 'utf8'), path.basename(to)))); if (fs.existsSync(path.join(s.dir, 'sections'))) copyDir(path.join(s.dir, 'sections'), path.join(to, 'sections')); }; if (mode === 'all') { @@ -338,7 +348,7 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod const to = path.join(dest, path.basename(s.dir)); if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to); } - return { files, router: null, skills: skills.length }; + return { files, router: null, names: skills.map(s => t.render === 'host' ? s.name : path.basename(s.dir)) }; } const routerDir = path.join(dest, 'gstack'); const entries = skills.map(s => { @@ -349,7 +359,7 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod }); const router = path.join(routerDir, 'SKILL.md'); files.set(router, Buffer.from(routerSkill(entries, t))); - return { files, router, skills: skills.length }; + return { files, router, names: ['gstack', ...entries.map(e => e.name)] }; } function backupFile(file: string, stateRoot: string, stamp: string): void { @@ -396,7 +406,9 @@ export function register(opts: RegisterOptions): RegisterResult { const prev = readLedger(lfile); const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-')); try { - const { files, router, skills } = planPayload(opts, dest, mode, scratch); + const { files, router, names } = planPayload(opts, dest, mode, scratch); + const skills = mode === 'router' ? names.length - 1 : names.length; + const visible = mode === 'router' ? ['gstack'] : names; const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha])); const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]); const owned = new Set([...prevFiles.keys()].map(topOf)); @@ -406,8 +418,15 @@ export function register(opts: RegisterOptions): RegisterResult { if (st.isSymbolicLink()) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', top + ' is a symlink'); if (!owned.has(top)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', top + ' already exists and gstack did not write it'); } + const ours = new Set([...files.keys()].map(topOf)); + if (fs.existsSync(dest)) for (const e of fs.readdirSync(dest)) { + const md = path.join(dest, e, 'SKILL.md'); + if (ours.has(path.join(dest, e)) || owned.has(path.join(dest, e)) || !fs.existsSync(md)) continue; + const other = frontmatterField(fs.readFileSync(md, 'utf8'), 'name'); + if (visible.includes(other)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'a skill named ' + other + ' already exists at ' + path.join(dest, e) + ' and gstack did not write it'); + } const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) })); - const same = prev && prev.meta.mode === mode && prev.files.length === want.length + const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length && want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha); const block = opts.context ? contextBlock(router ?? '') : null; if (block && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes'); @@ -435,7 +454,7 @@ export function register(opts: RegisterOptions): RegisterResult { written++; } writeLedger(lfile, { - meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, ...(router ? { router } : {}), ...(opts.context ? { context: path.resolve(opts.context) } : {}) }, + meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(opts.context ? { context: path.resolve(opts.context) } : {}) }, files: want, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks: opts.context ? [path.resolve(opts.context)] : [], }); } @@ -467,9 +486,9 @@ export function unregister(stateRoot: string, checkout: string, idOrTarget: stri } /** Re-register every file registration this checkout made (setup runs it on every pass, so upgrades refresh them). */ -export function refresh(base: Omit): RegisterResult[] { +export function refresh(base: Omit, skip: Set = new Set()): RegisterResult[] { return listLedgers(base.stateRoot) - .filter(({ ledger: l }) => l.meta.checkout === base.checkout && l.meta.target !== 'qm') + .filter(({ ledger: l }) => l.meta.checkout === base.checkout && l.meta.target !== 'qm' && !skip.has(l.meta.id)) .map(({ ledger: l }) => register({ ...base, target: getTarget(l.meta.target), mode: l.meta.mode as SkillsMode, skillsDir: l.meta.context && l.meta.dest.startsWith(ledgerDir(base.stateRoot)) ? undefined : l.meta.dest, @@ -477,19 +496,23 @@ export function refresh(base: Omit): RegisterResult[] })); } -export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string } +export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } -export function harnessSees(t: Target, dest: string, env: Env): { visible: 'yes' | 'no' | 'unknown'; checked?: string } { +/** Runs the harness's own skill list: 'yes' when every name shows (or, with any, at least one). */ +export function harnessSees(t: Target, dest: string, env: Env, names: string[], any = false): { visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } { const lc = t.listCommand?.(dest); - if (!lc || !onPath(lc.argv[0], env)) return { visible: 'unknown' }; - const r = spawnSync(lc.argv[0], lc.argv.slice(1), { env: { ...process.env, ...env, ...lc.env }, encoding: 'utf8', timeout: 60_000 }); - return { visible: lc.expect.test((r.stdout ?? '') + (r.stderr ?? '')) ? 'yes' : 'no', checked: lc.argv.join(' ') }; + if (!lc || !names.length || !onPath(lc.argv[0], env)) return { visible: 'unknown' }; + const r = spawnSync(lc.argv[0], lc.argv.slice(1), { env: { ...process.env, ...env, ...lc.env }, encoding: 'utf8', timeout: 120_000 }); + const text = (r.stdout ?? '') + (r.stderr ?? ''); + const missing = names.filter(n => !lc.listed(n).test(text)); + const ok = any ? missing.length < names.length : missing.length === 0; + return { visible: ok ? 'yes' : 'no', checked: lc.argv.join(' '), ...(ok || any ? {} : { missing }) }; } export function status(stateRoot: string, version: string, env: Env, check = true): StatusRow[] { return listLedgers(stateRoot).map(({ ledger: l }) => { const t = (TARGETS as Record)[l.meta.target]; - const seen = check && t ? harnessSees(t, l.meta.dest, env) : { visible: 'unknown' as const }; + const seen = check && t ? harnessSees(t, l.meta.dest, env, (l.meta.names ?? '').split(',').filter(Boolean)) : { visible: 'unknown' as const }; return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen }; }); } diff --git a/scripts/host-config.ts b/scripts/host-config.ts index 715c4c5f1..382982fdc 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -131,6 +131,12 @@ export interface HostConfig { * overwrite a user's own AGENTS.md. See scripts/gen-agents-digest.ts. */ instructionTier?: { rulesFile: string }; + /** + * 'harness': setup's arm for this host is `gstack-harness register ` + * (lib/harness-register.ts): the host's render copied into the harness's own + * skills folder with an ownership ledger, not linked into a host skills dir. + */ + registration?: 'harness'; }; // --- Host-Specific Behavioral Config --- @@ -189,6 +195,9 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read if (!['native', 'prose'].includes(caps.questions)) errors.push(`capabilities.questions must be 'native' or 'prose'`); if (!['enforced', 'advisory'].includes(caps.safetyHooks)) errors.push(`capabilities.safetyHooks must be 'enforced' or 'advisory'`); } + if (config.install.registration === 'harness' && config.tier !== 'experimental') { + errors.push('install.registration harness needs tier experimental (setup registers it through gstack-harness)'); + } if (config.tier === 'instruction-only' && !config.install.instructionTier && config.name !== 'slate' && config.name !== 'gbrain') { errors.push('instruction-only hosts must declare install.instructionTier (what setup tells the user to copy)'); } diff --git a/setup b/setup index 101cbc39a..d1cab33a9 100755 --- a/setup +++ b/setup @@ -23,8 +23,8 @@ Usage: ./setup [options] Options: --host Install for one host. Default: claude. Hosts by tier: full: claude - experimental: codex, kiro, factory, opencode, cursor, copilot, agy - instruction-only: slate, openclaw, hermes, gbrain + experimental: codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes + instruction-only: slate, gbrain auto: every installed agent that setup detects An explicit --host never changes another host's install. --global Allow this checkout to replace an existing global install @@ -42,6 +42,11 @@ Options: installs Chromium later, on the first browser skill run; bin/gstack-capy-install passes --no-browser unless it is given --with-browser. + --no-harness-auto Do not register gstack's skills with the agent harness this + setup runs inside (OpenClaw, Hermes, a Dot). Same as + GSTACK_HARNESS_AUTO=0. Registration only happens without + --host (or with --host auto); bin/gstack-harness registers + with any harness by hand (docs/hosts.md). --refresh-registered Refresh every install registered for this checkout (what /gstack-upgrade runs), then print one row per install. @@ -179,12 +184,13 @@ CLAUDE_MODEL_ARG="" CLAUDE_MODEL_SET=0 REFRESH_REGISTERED=0 HOST_FLAG_SET=0 +HARNESS_AUTO=1; [ "${GSTACK_HARNESS_AUTO:-1}" = 0 ] && HARNESS_AUTO=0 # Reject unknown options before anything is written, with the closest flag. _setup_unknown_option() { local bad="$1" next="${2:-}" best="" best_d=99 flag d value="" for flag in --host --model --claude-model --global --local --prefix --no-prefix --team --no-team \ --plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \ - --refresh-registered --status --no-browser --quiet --help; do + --refresh-registered --status --no-browser --no-harness-auto --quiet --help; do d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN { n = length(a); m = length(b) for (i = 0; i <= n; i++) d[i, 0] = i @@ -226,31 +232,15 @@ while [ $# -gt 0 ]; do --timeline-stop-hook=*) TIMELINE_STOP_HOOK_MODE="${1#--timeline-stop-hook=}"; shift ;; --refresh-registered) REFRESH_REGISTERED=1; shift ;; --no-browser) export GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_SKIP_FONTS=1; shift ;; + --no-harness-auto) HARNESS_AUTO=0; shift ;; -q|--quiet) QUIET=1; shift ;; *) _setup_unknown_option "$@"; exit 2 ;; esac done -# Shared by the instruction-tier explainer arms (openclaw, hermes). The digest -# path is anchored to the script's own directory — $(pwd) would print a -# nonexistent path when setup is invoked from anywhere else. -print_instruction_tier() { - local source_dir - source_dir="$(cd "$(dirname "$0")" && pwd -P)" - echo "" - echo "Instruction-only tier (no install): copy the 2KB rules digest into a" - echo "location your agent reads (e.g. append to your project's AGENTS.md)." - echo "It carries gstack's ethos, reuse ladder, and voice rules:" - echo "" - echo " $source_dir/agents-digest/gstack-AGENTS.md" - echo "" - echo "Re-copy it after upgrading gstack — the digest's first line shows its version." - echo "" -} - [ "$HOST" = "antigravity" ] && HOST="agy" # Antigravity CLI is named after its binary case "$HOST" in - claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto) ;; + claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto) ;; slate) echo "" echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —" @@ -261,33 +251,6 @@ case "$HOST" in echo " ./setup --host claude" echo "" exit 0 ;; - openclaw) - echo "" - echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" - echo "sessions natively via ACP. gstack provides methodology artifacts, not a" - echo "full skill installation." - echo "" - echo "To integrate gstack with OpenClaw:" - echo " 1. Tell your OpenClaw agent: 'install gstack for openclaw'" - echo " 2. Or generate artifacts: bun run gen:skill-docs --host openclaw" - echo " (in an install, add 'openclaw' to .gstack-installed-hosts first, or ./setup prunes them)" - echo " 3. See docs/OPENCLAW.md for the full architecture" - print_instruction_tier - exit 0 ;; - hermes) - echo "" - echo "Hermes is an instruction-only gstack host: setup installs nothing for" - echo "Hermes and changed nothing on this machine." - echo "" - echo "To use gstack with Hermes:" - echo " 1. Copy the rules digest below into a file Hermes reads, or" - echo " 2. Render Hermes-flavoured skills yourself (not certified; #2826):" - echo " bun run gen:skill-docs --host hermes" - echo " then point a Hermes profile's skills.external_dirs at .hermes/skills." - echo " Add 'hermes' to .gstack-installed-hosts in this checkout first: ./setup" - echo " prunes renders of hosts that file does not name (#1694)." - print_instruction_tier - exit 0 ;; gbrain) echo "" echo "GBrain is a mod for gstack — it makes coding skills brain-aware." @@ -553,6 +516,9 @@ claude "*) _persist_claude_model ;; $_RF_TARGETS EOF grep -q ' failed ' "$_RF_SUMMARY" && _RF_FAILED=1 + if [ "$HARNESS_AUTO" -eq 1 ] && [ -d "$GSTACK_STATE_ROOT/harness" ]; then + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" refresh || _RF_FAILED=1 + fi echo "" if [ "$_RF_FAILED" -eq 1 ]; then echo "Upgrade summary (NOT every install was refreshed; see failed rows):" @@ -1414,6 +1380,7 @@ INSTALL_OPENCODE=0 INSTALL_CURSOR=0 INSTALL_COPILOT=0 INSTALL_AGY=0 +INSTALL_HARNESS="" if [ "$HOST" = "auto" ]; then command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1 @@ -1450,13 +1417,15 @@ elif [ "$HOST" = "copilot" ]; then INSTALL_COPILOT=1 elif [ "$HOST" = "agy" ]; then INSTALL_AGY=1 +elif [ "$HOST" = "openclaw" ] || [ "$HOST" = "hermes" ]; then + INSTALL_HARNESS="$HOST" fi # A host that passes --host validation but sets no INSTALL_* flag would # silently configure nothing and exit 0 (the #2361 slate failure class). # Fail loudly if a future host lands in the accept-list without a dispatch arm. -if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then - echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy (informational: slate, openclaw, hermes, gbrain)." >&2 +if [ "$HOST" != "auto" ] && [ -z "$INSTALL_HARNESS" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then + echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy, openclaw, hermes (informational: slate, gbrain)." >&2 exit 1 fi @@ -2354,6 +2323,25 @@ fi # 3. Ensure ~/.gstack global state directory exists mkdir -p "$HOME/.gstack/projects" +# OpenClaw and Hermes: gstack-harness copies this checkout's render for the +# harness into the harness's own skills folder, records every file in a ledger +# (bin/gstack-uninstall removes exactly those) and never edits harness config +# (lib/harness-register.ts). Nothing else in this run applies to them. +_gstack_harness() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" "$@"; } +if [ -n "$INSTALL_HARNESS" ]; then + _setup_arm_begin "$INSTALL_HARNESS" global "$INSTALL_HARNESS" + if ! _HARNESS_JSON="$(_gstack_harness register "$INSTALL_HARNESS" --json --no-registry)"; then + _SETUP_REFUSED="gstack-harness could not register $INSTALL_HARNESS (reason above); nothing was changed for it" + exit 1 + fi + _HARNESS_DEST="$(printf '%s\n' "$_HARNESS_JSON" | sed -n 's/.*"dest":"\([^"]*\)".*/\1/p')" + _setup_arm_publish "$INSTALL_HARNESS" global - "$_HARNESS_DEST" "$SOURCE_GSTACK_DIR" - - + log "$(printf '%s\n' "$_HARNESS_JSON" | sed -n 's/.*"receipt":"\([^"]*\)".*/\1/p')" + _setup_print_summary + printf '%s' "$_SETUP_ROWS" | _setup_ready_line + exit 0 +fi + # ─── Helper: link a skill's runtime assets into its installed dir ──────────── # Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317, # #2454): review/checklist.md + specialists/, qa/templates + references, @@ -4810,4 +4798,11 @@ if [ -n "${_HOOK_REFUSED:-}" ]; then fi # The final line names every host this run installed, from the same rows as # the summary table; instruction-only hosts write no row and print none. +# Inside an agent harness (OpenClaw, Hermes, a Dot) with no --host naming +# another host, register gstack's skills with that harness too, and refresh the +# harness registrations this checkout made before (docs/hosts.md). +if [ "$HARNESS_AUTO" -eq 1 ] && [ "$LOCAL_INSTALL" -eq 0 ] && { [ "$HOST_FLAG_SET" -eq 0 ] || [ "$HOST" = auto ]; }; then + _HARNESS_OUT="$(_gstack_harness auto 2>&1)" || log "gstack-harness: registration with the harness failed; everything else is installed. Retry: $SOURCE_GSTACK_DIR/bin/gstack-harness auto" + [ -z "$_HARNESS_OUT" ] || { log ""; log "$_HARNESS_OUT"; log " Turn this off: ./setup --no-harness-auto (or GSTACK_HARNESS_AUTO=0)"; } +fi printf '%s' "$_SETUP_ROWS" | _setup_ready_line diff --git a/test/harness-register.test.ts b/test/harness-register.test.ts new file mode 100644 index 000000000..116c5a4a7 --- /dev/null +++ b/test/harness-register.test.ts @@ -0,0 +1,260 @@ +/** + * gstack-harness (GSTA-135): registering gstack's skills with the agent harness + * it is installed into, and taking them back out. Unit cases call the registrar + * in-process against temporary harness folders; setup cases run the real + * ./setup and bin/gstack-uninstall in the install fixture (test/helpers/install-fixture.ts). + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS, + contextBlock, detect, listLedgers, readLedger, register, unregister, type RegisterOptions, +} from '../lib/harness-register'; +import { qmRequest, registerQm, removeQmPack } from '../lib/harness-qm'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(); +const temps: string[] = []; +const tmp = () => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-test-'))); temps.push(d); return d; }; +afterEach(() => { cleanupFixtures(); for (const d of temps.splice(0)) fs.rmSync(d, { recursive: true, force: true }); }); +afterAll(cleanupSeed); + +function opts(dir: string, target: keyof typeof TARGETS, extra: Partial = {}): RegisterOptions { + return { target: TARGETS[target], checkout: ROOT, stateRoot: path.join(dir, 'state'), env: {}, home: path.join(dir, 'home'), version: VERSION, ...extra }; +} +const files = (dir: string): string[] => fs.existsSync(dir) + ? fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(e => e.isFile()).map(e => path.relative(dir, path.join(e.parentPath, e.name))).sort() : []; + +/** feder-cr/dots nanobot/dots/skills.py parse_skill: single-line fields, name equal to its folder, description 1..1024. */ +function dotsAccepts(md: string, folder: string): boolean { + const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md); + if (!m) return false; + const fields: Record = {}; + for (const line of m[1].split(/\r?\n/)) { const f = /^([a-z]+):[ \t]*(.*?)[ \t]*$/.exec(line); if (f) fields[f[1]] = f[2].replace(/^["']+|["']+$/g, ''); } + const name = fields.name ?? ''; const description = fields.description ?? ''; + return name === folder && /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name) && description.length >= 1 && description.length <= 1024; +} + +describe('gstack-harness budgets and reason codes', () => { + test('every-session text stays within its budget', () => { + expect(ROUTER_DESCRIPTION.length).toBeLessThanOrEqual(ROUTER_DESCRIPTION_MAX); + const block = contextBlock('/home/someone/.local/share/agent-harness/skills/gstack/SKILL.md'); + expect(block.length).toBeLessThanOrEqual(CONTEXT_BLOCK_MAX); + expect(block.startsWith(BLOCK_BEGIN) && block.endsWith(BLOCK_END)).toBe(true); + }); + + test('every reason code has a section in docs/troubleshooting.md', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8'); + for (const [code, c] of Object.entries(HARNESS_CODES)) { + const at = doc.indexOf(''); + expect(at, code).toBeGreaterThan(-1); + expect(doc.slice(at).split('\n')[1], code).toMatch(/^#{2,4} /); + } + }); + + test('inside-harness detection reads the markers each harness sets for its commands', () => { + const home = tmp(); + expect(detect({ OPENCLAW_SHELL: 'exec' }, home, []).inside).toEqual(['openclaw']); + expect(detect({ HERMES_AGENT: 'true' }, home, []).inside).toEqual(['hermes']); + expect(detect({ AI_AGENT: 'hermes-agent' }, home, []).inside).toEqual(['hermes']); + expect(detect({}, home, ['bash -c', 'node openclaw.mjs']).inside).toEqual(['openclaw']); + expect(detect({}, home, ['python3 hermes']).inside).toEqual(['hermes']); + expect(detect({ OPENCLAW_SHELL: 'interactive' }, home, ['node server.js', 'vim openclaw.json']).inside).toEqual([]); + }); +}); + +describe('gstack-harness register and unregister (skill folders)', () => { + test('OpenClaw router: one top-level skill, idempotent, removed exactly, user skills survive', () => { + const d = tmp(); + const skills = path.join(d, 'oc', 'skills'); + fs.mkdirSync(path.join(skills, 'mine'), { recursive: true }); + fs.writeFileSync(path.join(skills, 'mine', 'SKILL.md'), '---\nname: mine\ndescription: my own\n---\nmine\n'); + const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } })); + expect(r.mode).toBe('router'); + expect(r.skills).toBeGreaterThan(20); + expect(fs.readdirSync(skills).sort()).toEqual(['gstack', 'mine']); + const router = fs.readFileSync(path.join(skills, 'gstack', 'SKILL.md'), 'utf8'); + expect(router).toContain('description: ' + ROUTER_DESCRIPTION); + for (const [, p] of router.matchAll(/Read \x60([^\x60]+)\x60/g)) expect(fs.existsSync(p), p).toBe(true); + expect(register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } })).status).toBe('unchanged'); + unregister(path.join(d, 'state'), ROOT, r.id); + expect(files(skills)).toEqual(['mine/SKILL.md']); + expect(listLedgers(path.join(d, 'state'))).toEqual([]); + }, 60_000); + + test('OpenClaw all: every skill a top-level folder with a unique name', () => { + const d = tmp(); + const r = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'skills') })); + const names = fs.readdirSync(path.join(d, 'skills')).map(n => fs.readFileSync(path.join(d, 'skills', n, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m)![1]); + expect(names.length).toBe(r.skills); + expect(new Set(names).size).toBe(names.length); + expect(readLedger(r.ledger)!.meta.names.split(',').sort()).toEqual([...names].sort()); + }, 60_000); + + test('Hermes: every skill in one gstack category, no runtime folder a scan could take for a skill', () => { + const d = tmp(); + const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } })); + const dest = path.join(d, 'hermes', 'skills', 'gstack'); + expect(r.dest).toBe(dest); + const mds = files(dest).filter(f => f.endsWith('SKILL.md')); + expect(mds.every(f => f.split('/').length === 2), mds.filter(f => f.split('/').length !== 2).join(', ')).toBe(true); + for (const f of mds) expect(fs.readFileSync(path.join(dest, f), 'utf8')).toMatch(new RegExp('^---\\nname: ' + path.dirname(f) + '\\n')); + expect(mds.length).toBe(r.skills); + }, 60_000); + + test('refuses an entry gstack did not write, a same-named skill, a symlinked folder, and unsafe destinations', () => { + const d = tmp(); + const skills = path.join(d, 'skills'); + fs.mkdirSync(path.join(skills, 'gstack'), { recursive: true }); + const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } }; + expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNOWNED_ENTRY'); + expect(files(skills)).toEqual([]); + fs.rmSync(path.join(skills, 'gstack'), { recursive: true }); + fs.mkdirSync(path.join(skills, 'other')); + fs.writeFileSync(path.join(skills, 'other', 'SKILL.md'), '---\nname: gstack\ndescription: x\n---\nx\n'); + expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNOWNED_ENTRY'); + fs.rmSync(path.join(skills, 'other'), { recursive: true }); + fs.symlinkSync(d, path.join(skills, 'gstack')); + expect(code(() => register(opts(d, 'dots', { skillsDir: skills })))).toBe('HARNESS_UNSAFE_DESTINATION'); + expect(code(() => register(opts(d, 'dots', { skillsDir: path.join(ROOT, '.agents', 'skills') })))).toBe('HARNESS_UNSAFE_DESTINATION'); + expect(code(() => register(opts(d, 'dots', { skillsDir: path.join(d, 'home') })))).toBe('HARNESS_UNSAFE_DESTINATION'); + expect(code(() => register(opts(d, 'generic')))).toBe('HARNESS_NO_DESTINATION'); + expect(code(() => register(opts(d, 'hermes', { mode: 'router', env: { HERMES_HOME: path.join(d, 'h') } })))).toBe('HARNESS_MODE_UNSUPPORTED'); + }, 60_000); + + test('dots: router and every skill pass the engine\'s skill parser', () => { + expect(TARGETS.dots.dest({}, '/root')).toBe(DOT_SKILLS_DIR); + const d = tmp(); + register(opts(d, 'dots', { skillsDir: path.join(d, 'router') })); + expect(dotsAccepts(fs.readFileSync(path.join(d, 'router', 'gstack', 'SKILL.md'), 'utf8'), 'gstack')).toBe(true); + register(opts(d, 'dots', { mode: 'all', skillsDir: path.join(d, 'all') })); + for (const name of fs.readdirSync(path.join(d, 'all'))) { + const md = path.join(d, 'all', name, 'SKILL.md'); + expect(dotsAccepts(fs.readFileSync(md, 'utf8'), name), name).toBe(true); + expect(fs.statSync(md).size).toBeLessThanOrEqual(256 * 1024); + } + }, 60_000); + + test('generic: the context block is replaced in place, then cut out; the user\'s lines and edits survive', () => { + const d = tmp(); + const ctx = path.join(d, 'agent', 'AGENTS.md'); + fs.mkdirSync(path.dirname(ctx), { recursive: true }); + fs.writeFileSync(ctx, '# my rules\nbe kind\n'); + const r = register(opts(d, 'generic', { context: ctx })); + expect(fs.readFileSync(ctx, 'utf8').split(BLOCK_BEGIN).length).toBe(2); + register(opts(d, 'generic', { context: ctx })); + expect(fs.readFileSync(ctx, 'utf8').split(BLOCK_BEGIN).length).toBe(2); + fs.appendFileSync(r.router!, '\nmy note\n'); + unregister(path.join(d, 'state'), ROOT, 'generic'); + expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n\n'); + expect(fs.existsSync(r.router!)).toBe(false); + const kept = files(path.join(d, 'state', 'backups', 'harness')); + expect(kept.length).toBe(1); + expect(kept[0].endsWith('gstack/SKILL.md')).toBe(true); + }, 60_000); +}); + +describe('gstack-harness QM (org skill pack, against a fake admin API)', () => { + function fakeQm(failImports = 0) { + const packs: Array<{ id: string; url: string; ref: string; config: unknown }> = []; + const calls: string[] = []; + let fails = failImports; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const u = new URL(req.url); + calls.push(req.method + ' ' + u.pathname); + if (req.headers.get('authorization') !== 'Bearer t0k') return new Response('{"error":"unauthorized"}', { status: 401 }); + if (req.method === 'GET' && u.pathname === '/v1/admin/skill-packs') return Response.json({ packs }); + if (req.method === 'POST' && u.pathname === '/v1/admin/skill-packs') { + const b = await req.json() as { url: string; ref: string; config: unknown }; + const pack = { id: 'pk' + (packs.length + 1), url: b.url, ref: b.ref, config: b.config }; + packs.push(pack); + return Response.json({ pack }); + } + if (req.method === 'POST' && u.pathname.endsWith('/import')) { + if (fails-- > 0) return new Response('{"error":"boom"}', { status: 502 }); + return Response.json({ imported: ['review', 'ship'], excluded: [] }); + } + if (req.method === 'DELETE') { packs.splice(packs.findIndex(p => u.pathname.endsWith(p.id)), 1); return Response.json({ removed: true }); } + return new Response('not found', { status: 404 }); + }, + }); + return { server, packs, calls, env: { GSTACK_QM_URL: 'http://127.0.0.1:' + server.port, GSTACK_QM_ADMIN_TOKEN: 't0k' } }; + } + + test('without a credential or --yes it refuses and prints the request, never a token', async () => { + const d = tmp(); + const err = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: {}, version: VERSION, yes: true }).catch(e => e); + expect(err.code).toBe('HARNESS_QM_CREDENTIALS'); + expect(err.message).toContain('/v1/admin/skill-packs'); + const noYes = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: { GSTACK_QM_URL: 'http://127.0.0.1:9', GSTACK_QM_ADMIN_TOKEN: 'sekret' }, version: VERSION, yes: false }).catch(e => e); + expect(noYes.code).toBe('HARNESS_QM_CREDENTIALS'); + expect(noYes.message).not.toContain('sekret'); + expect(qmRequest('abc')).toContain('"ref":"abc"'); + }); + + test('registers once, a retry after a failed import reuses the pack, two machines share it, --remote archives it', async () => { + const q = fakeQm(1); + try { + const a = tmp(); const b = tmp(); + const r1 = await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true }); + expect(q.packs.length).toBe(1); + expect(q.calls.filter(c => c === 'POST /v1/admin/skill-packs').length).toBe(1); + expect(q.calls.filter(c => c.endsWith('/import')).length).toBe(2); + const ledger = fs.readFileSync(path.join(a, 'state', 'harness', r1.id + '.ledger'), 'utf8'); + expect(ledger).toContain('qm_pack\tpk1'); + expect(ledger).not.toContain('t0k'); + expect((await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged'); + await registerQm({ checkout: ROOT, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true }); + expect(q.packs.length).toBe(1); + expect(await removeQmPack(path.join(a, 'state'), q.env, r1.id)).toBe('pk1'); + expect(q.packs.length).toBe(0); + } finally { q.server.stop(true); } + }); +}); + +describe.skipIf(process.platform === 'win32')('setup and uninstall with harnesses', () => { + const harnessDirs = ['.openclaw', '.hermes']; + for (const [host, dest, probe] of [['openclaw', '.openclaw/skills', 'gstack/SKILL.md'], ['hermes', '.hermes/skills/gstack', 'gstack-review/SKILL.md']] as const) { + test(host + ': ./setup --host ' + host + ' registers, reruns unchanged, and gstack-uninstall removes it', () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.dir, 'gstack')); + const r = runSetup(f, path.join(src, 'setup'), ['--host', host]); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.existsSync(path.join(f.home, dest, probe))).toBe(true); + for (const other of ['.claude/skills', '.codex/skills', ...harnessDirs.filter(h => !dest.startsWith(h))]) expect(fs.existsSync(path.join(f.home, other)), other).toBe(false); + expect(registryRows(f).map(row => [row[0], row[3], row[5]])).toEqual([[host, path.join(f.home, dest), fs.realpathSync(src)]]); + expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack ready (' + host + ')'); + const again = runSetup(f, path.join(src, 'setup'), ['--host', host]); + expect(again.stdout + again.stderr).toMatch(new RegExp('\\b' + host + '\\s+experimental\\s+global\\s+unchanged')); + const un = spawnSync('bash', [path.join(src, 'bin/gstack-uninstall'), '--force'], { cwd: f.home, env: { ...f.env, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 60_000 }); + expect(un.status, un.stdout + un.stderr).toBe(0); + expect(files(path.join(f.home, dest)).filter(p => !p.startsWith('.'))).toEqual([]); + }, 120_000); + } + + test('setup inside OpenClaw registers it too; --host claude or the off switch writes nothing under OpenClaw', () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.dir, 'gstack')); + const inside = { OPENCLAW_SHELL: 'exec' }; + const off = runSetup(f, path.join(src, 'setup'), [], { env: { ...inside, GSTACK_HARNESS_AUTO: '0' } }); + expect(off.status, off.stdout + off.stderr).toBe(0); + expect(fs.existsSync(path.join(f.home, '.openclaw'))).toBe(false); + const explicit = runSetup(f, path.join(src, 'setup'), ['--host', 'claude'], { env: inside }); + expect(explicit.status).toBe(0); + expect(fs.existsSync(path.join(f.home, '.openclaw'))).toBe(false); + const before = tree(path.join(f.home, '.claude')); + const auto = runSetup(f, path.join(src, 'setup'), [], { env: inside }); + expect(auto.status, auto.stdout + auto.stderr).toBe(0); + expect(fs.existsSync(path.join(f.home, '.openclaw/skills/gstack/SKILL.md'))).toBe(true); + expect(auto.stdout).toContain('gstack-harness: OpenClaw:'); + expect(auto.stdout).toContain('--no-harness-auto'); + expect(auto.stdout.trim().split('\n').at(-1)).toBe('gstack ready (claude)'); + expect(tree(path.join(f.home, '.claude'))).toEqual(before); + }, 180_000); +});