v1.91.290.0 feat(harness): gstack registers its skills in whatever agent harness it is installed into (GSTA-135) (#3526)

* wip: gstack-harness registrar (GSTA-135)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* wip: harness registration in setup, uninstall, tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* wip: harness hosts experimental, troubleshooting codes, kit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: agent harness registration (GSTA-135)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: harness hosts run in place from the checkout in --status

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: bun-free ledger reader

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: CHANGELOG for harness registration (Unreleased)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(harness): keep edits beside themselves, never follow symlinks, cut only gstack's own context block

Review blockers on #3526: uninstall deleted the edit backups under the state root;
unregister followed a symlinked folder below the destination; a lone or edited
context marker made the cut eat user lines; a re-register adding --context left an
unrecorded block. Also: ledger written before the files, --refresh-registered no
longer re-runs harness hosts through setup (gstack-harness refresh keeps their mode),
bash goes through runBashSync, and setup tests follow openclaw/hermes becoming installs.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(harness): a ledger with no destination row touches nothing (no hang)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: harness registration in README, install and hosts (document-release audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-28 04:33:53 +00:00
1 parent a14c28aca7
commit 44f19d0106
28 files changed
+1549 -129

No files matched your search

+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [1.91.290.0] - 2026-10-28
**Installing gstack inside an agent harness now gives that harness gstack's skills, the harness's own way.** Until now OpenClaw and Hermes were instruction-only: `./setup --host hermes` printed a digest path and changed nothing, so an agent working inside OpenClaw or Hermes could not find a single gstack skill until a person wired one in by hand, and QM, dots, Muse Code and Grok were unknown to gstack (Garry, 2026-10-27: "when gstack is installed inside OpenClaw, Hermes, qm, muse, grok bot, dots or any harness it auto resolves and figures out how to use the skills"). `./setup` run inside OpenClaw, Hermes or a Dot now registers gstack there in the same run; `gstack-uninstall` takes it back out.
### Added
- **`bin/gstack-harness`** (`lib/harness-register.ts`, `lib/harness-qm.ts`): `register`, `unregister`, `status`, `detect`, `refresh`, `auto` and `manifest` for seven targets. OpenClaw gets one `gstack` router skill (one 254-byte description line in every request; its body lists every skill with its path) in `$OPENCLAW_STATE_DIR/skills`, or every skill with `--skills all`. Hermes gets every skill as `gstack-<skill>` in one `gstack` category, because it opens only skills it has indexed. A Dot gets the router in `/home/dot/skills`. QM gets an org skill pack (`github.com/garrytan/gstack` at the installed commit) through one explicit admin command, `register qm --yes`, which lists packs first so a retry never makes a second one. Muse Code and Grok Build already read `~/.claude/skills`; `status` reports them as discovered, Claude-rendered and unverified. Any other harness gets the router in a skills folder (`--skills-dir`) or a routing block of at most 600 bytes between `gstack:begin`/`gstack:end` markers in a context file (`--context`), and `--print` writes both to stdout for chat-only apps such as Grok Bot.
- **Ownership ledger**: every file gstack-harness writes goes into `$GSTACK_STATE_ROOT/harness/<id>.ledger` with its sha256. Registering again changes only what changed; a folder or a skill name gstack did not write stops it; a destination that is your home, the checkout, a symlink or outside a plain path is refused. `gstack-uninstall` reads the ledgers with `bin/gstack-harness-ledger.sh` (bash only) before it removes the checkout, never follows a symlink below the destination, and renames any file you edited to `<file>.edited-<time>` beside itself, so the edit outlives the uninstall. A `--context` block is cut only when its file holds exactly the block gstack wrote (whole-line markers, hash in the ledger); a lone marker or an edited block is left as it is.
- **Ten reason codes** (`HARNESS_*`), each with a fix line and a `docs/troubleshooting.md` section.
### Changed
- **OpenClaw and Hermes are `experimental` hosts** with `install.registration: 'harness'`: `./setup --host openclaw|hermes` registers through gstack-harness, publishes an install-registry row and ends with `gstack ready (<host>)`; `./setup --refresh-registered` (what `/gstack-upgrade` runs) refreshes every registration. OpenClaw's ACP route is unchanged.
- **`./setup` with no `--host` (or `--host auto`) inside a harness** registers with it, detected from the marker the harness sets for its agent's commands (`OPENCLAW_SHELL=exec`, `HERMES_AGENT=true`, a Dot's `HOME=/home/dot`) or the harness among setup's parent processes. An explicit `--host claude` writes nothing under a harness folder. `--no-harness-auto` or `GSTACK_HARNESS_AUTO=0` turns it off. Capy's installer names its hosts, so nothing changes on Capy machines.
### Removed
- `install.instructionTier` from the host config and the setup arms that printed the digest path for OpenClaw and Hermes; the 2KB digest itself stays for rules-reading agents.
### Fixed
- `./setup --status` no longer tells an OpenClaw or Hermes install to move the checkout out of its runtime root, because those hosts run from the checkout by design, as Claude does.
### Tests
- `test/harness-register.test.ts`: per-target registration against temporary harness folders (idempotent rerun, exact removal, user skills and context lines kept, edited files kept beside themselves through a full uninstall, a lone, repeated or edited context block left alone, a moved `--context` recorded, a symlinked folder below the destination never followed), the refusals, the Dot engine's skill-parser rules, the budgets, every reason code's anchor, inside-harness detection, the bun-free ledger reader, QM against a fake admin API (a failed import retried without a second pack, two machines sharing one pack, `--remote` archiving), and the real `./setup` and `gstack-uninstall` for `--host openclaw`, `--host hermes` and setup run inside OpenClaw.
### Docs
- docs/hosts.md § Agent harnesses (install, verify and remove for each harness), docs/install.md, docs/uninstall.md, docs/OPENCLAW.md (native skills or ACP), docs/ADDING_A_HOST.md (harness hosts and how to add a target), docs/capy-install.md, README host matrix.
## [1.91.289.0] - 2026-10-28
**/plan-design-review, /office-hours and /design-review load 15 to 40 percent fewer bytes per run, with the same or better task results.** Garry picked the skill-diet pilot on 2026-10-27 (A1): "pilot now with the upstream skill-diet on two or three large skills nobody is editing this week (design-review, office-hours, plan-design-review). Each slimmed skill ships only if it passes skill-diet's before/after test and gstack's own paid tests for that skill." All three passed both. Each SKILL.md now holds what every run needs; blocks only some runs use moved into on-demand sections behind a one-line "read when" pointer, and prose the model already knows was cut. Descriptions and triggers are unchanged.
+9 -7
View File
@@ -47,8 +47,10 @@ Team mode's `required` hook runs on Node.js, so every teammate needs Node.js on
### Other AI Agents
gstack runs on more agents than Claude. Bare `./setup` installs for Claude
Code only. Pick another agent with `--host <name>`, or install for every agent
setup detects on this machine with `--host auto`:
Code only (run inside OpenClaw, Hermes or a Dot, it also registers gstack's
skills with that harness: [docs/hosts.md](docs/hosts.md#agent-harnesses)).
Pick another agent with `--host <name>`, or install for every agent setup
detects on this machine with `--host auto`:
```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
@@ -76,11 +78,11 @@ 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).
Per-host notes (agent harnesses, 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).
## See it work
@@ -241,8 +243,8 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers |
| [Other agents](docs/hosts.md) | OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers, agent harness registration |
| [Other agents](docs/hosts.md) | Agent harnesses (OpenClaw, Hermes, dots, QM, Muse Code, Grok, any other), OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
| [QA without a webpage](docs/qa-without-a-webpage.md) | `/qa` and `/qa-only` on CLIs, APIs, jobs, workers and webhooks |
| [Standalone binaries](docs/standalone-binaries.md) | The standalone `gstack-*` CLIs (egress auditor, context bill, model benchmark, the review and evidence ledgers, the iOS QA daemon), and the CSO evaluation producer contract |
| [Privacy & Telemetry](docs/privacy.md) | What is sent, what never is, the update check, outside reviews, egress receipts |
+1 -1
View File
@@ -1 +1 @@
1.91.289.0
1.91.290.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.289.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.290.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-harness — register gstack's skills with the agent harness it is
* installed into (OpenClaw, Hermes, dots, QM, any other), the harness's own
* way, and take them back out. lib/harness-register.ts holds the targets and
* the ledger; docs/hosts.md has the per-harness install, verify and uninstall
* blocks.
*
* Usage:
* gstack-harness detect [--json]
* gstack-harness register <target> [--skills router|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
* gstack-harness unregister <target|registration id> [--remote]
* gstack-harness status [--json] [--no-check]
* gstack-harness refresh
* gstack-harness auto
* gstack-harness manifest <target> [--skills-dir <dir>]
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { resolveStateRoot } from '../lib/state-root';
import {
HarnessError, TARGETS, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
type RegisterResult, type SkillsMode, type TargetName,
} from '../lib/harness-register';
import { registerQm, removeQmPack } from '../lib/harness-qm';
const argv = process.argv.slice(2);
const flag = (name: string) => { const i = argv.indexOf(name); if (i < 0) return undefined; const v = argv[i + 1]; argv.splice(i, 2); return v; };
const bool = (name: string) => { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; };
const checkout = path.resolve(fs.realpathSync(process.argv[1]), '..', '..');
const version = (() => { try { return fs.readFileSync(path.join(checkout, 'VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();
const stateRoot = resolveStateRoot(process.env);
const env = process.env;
const home = os.homedir();
const self = path.join(checkout, 'bin', 'gstack-harness');
function receiptLines(r: RegisterResult): string[] {
const t = TARGETS[r.target];
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)' : '')] : []),
...r.kept.map(k => ' kept your edit: ' + k),
' 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');
(code ? console.error : console.log)(text);
process.exit(code);
}
async function main(): Promise<number> {
const json = bool('--json');
const sub = argv.shift();
const base = { checkout, stateRoot, env, home, version };
switch (sub) {
case 'detect': {
const d = detect(env, home);
if (json) console.log(JSON.stringify(d));
else console.log('inside: ' + (d.inside.join(', ') || 'none') + '\npresent: ' + (d.present.join(', ') || 'none'));
return 0;
}
case 'register': {
const mode = flag('--skills') as SkillsMode | undefined;
const skillsDir = flag('--skills-dir'); const context = flag('--context');
const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry');
const t = getTarget(argv.shift() ?? '');
if (print) { console.log(printPayload(checkout)); return 0; }
if (t.name === 'qm') {
const r = await registerQm({ ...base, yes });
console.log('gstack-harness: QM: pack ' + r.packId + ' at ' + r.ref + ' (' + r.status + '); imported: ' + JSON.stringify(r.imported).slice(0, 300));
console.log(' detach locally: ' + self + ' unregister ' + r.id + ' archive the org pack: ' + self + ' unregister ' + r.id + ' --remote');
return 0;
}
if (t.mechanism === 'verify-only') {
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;
}
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 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 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 + ')' : '') + (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;
}
case 'refresh': {
for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r);
return 0;
}
case 'auto': {
const d = detect(env, home);
let failed = 0;
const done = new Set<string>();
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
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 failed;
}
case 'manifest': {
const t = getTarget(argv.shift() ?? 'generic');
const skills = sourceSkills(t.render ? t : TARGETS.generic, checkout, fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-')));
console.log(JSON.stringify({ schema: 1, gstack_version: version, target: t.name, skills: skills.map(s => ({ name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') })) }, null, 2));
return 0;
}
case '--help': case '-h': case 'help': return usage(0);
default: return usage(2);
}
}
main().then(code => process.exit(code), (e) => {
if (e instanceof HarnessError) { console.error(e.line()); process.exit(1); }
throw e;
});
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# gstack-harness-ledger.sh — sourced; removes what gstack-harness registered,
# from its ledger, without bun (bin/gstack-uninstall runs it before it removes
# the checkout and the state root). Ledger format: lib/harness-register.ts.
# Callers set GSTACK_STATE_ROOT first (bin/gstack-state-root.sh).
#
# gstack_harness_unregister_ledger <ledger> remove one registration
# gstack_harness_unregister_all remove every registration under
# $GSTACK_STATE_ROOT/harness
#
# A file whose bytes still match the ledger is removed; an edited one is renamed
# in place to <file>.edited-<stamp>, a name no harness loads, so it survives the
# uninstall of the state root and the checkout. Only paths below the recorded
# destination, reached through no symlink, are touched. A context block is cut
# only when its file holds exactly one whole-line begin/end marker pair whose
# lines hash to the ledger's block hash; otherwise the file is left as it is
# (the file is removed only when nothing else is left in it). Folders gstack
# created are removed only when empty. A QM pack is never touched here: detaching it from
# the org is `gstack-harness unregister qm --remote`.
_gstack_harness_sha() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1
else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi
}
# True when $1 is below $2 and no path from $2 down to $1 is a symlink.
_gstack_harness_owned() {
local p="$1" dest="$2"
[ -n "$dest" ] || return 1
case "$p" in "$dest"/*) ;; *) return 1 ;; esac
while [ "$p" != "$dest" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done
[ ! -L "$dest" ]
}
_gstack_harness_cut_block() {
local file="$1" want="$2" span s e tmp
local begin='<!-- gstack:begin harness-routing -->' end='<!-- gstack:end harness-routing -->'
{ [ -f "$file" ] && [ ! -L "$file" ]; } || return 0
span="$(awk -v B="$begin" -v E="$end" '$0 == B { nb++; s = NR } $0 == E { ne++; e = NR } END { if (nb == 1 && ne == 1 && e > s) print s, e; else if (nb || ne) print "bad" }' "$file")"
[ -n "$span" ] || return 0
s="${span% *}"; e="${span#* }"
if [ "$span" = bad ] || [ "$(awk -v s="$s" -v e="$e" 'NR >= s && NR <= e' "$file" | _gstack_harness_sha)" != "$want" ]; then
echo " left $file as it is: its gstack block was edited; delete the lines from $begin to $end yourself"
return 0
fi
tmp="$file.gstack-cut.$$"
awk -v s="$s" -v e="$e" 'NR < s || NR > e' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$file"; else cat "$tmp" > "$file"; fi
rm -f "$tmp"
}
gstack_harness_unregister_ledger() {
local ledger="$1" stamp kind a b target="" dest="" pack=""
: "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}"
[ -f "$ledger" ] || return 0
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
while IFS=$'\t' read -r kind a b; do
case "$kind" in
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;;
file)
{ [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;;
block) _gstack_harness_cut_block "$b" "$a" ;;
esac
done < "$ledger"
while IFS=$'\t' read -r kind a b; do
[ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue
case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac
rmdir "$b" 2>/dev/null
done < "$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"
}
gstack_harness_unregister_all() {
local root="${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}" l
for l in "$root"/harness/*.ledger; do
[ -f "$l" ] || continue
gstack_harness_unregister_ledger "$l" && echo "harness: $(basename "$l" .ledger)"
done
}
_gstack_harness_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$_gstack_harness_dir/gstack-install-registry.sh" ] && . "$_gstack_harness_dir/gstack-install-registry.sh"
+7 -5
View File
@@ -33,11 +33,13 @@
# (#3084): print the commands that move it out first and return 0. A symlinked
# ~/.claude/skills/gstack that points at SRC becomes the checkout itself.
# Kiro writes its runtime files into ROOT in place, which would link the
# checkout into itself, so it counts too. Claude installs in place from the
# checkout, and a ROOT that is a symlink is replaced as a link: those return 1.
# checkout into itself, so it counts too. Claude and the harness hosts
# (OpenClaw, Hermes: gstack-harness copies skills that call the checkout) run
# in place from the checkout, and a ROOT that is a symlink is replaced as a
# link: those return 1.
gstack_install_move_hint() {
local src="$1" host="$2" root="$3" claude_link="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack"
[ "$host" = claude ] && return 1
case "$host" in claude|openclaw|hermes) return 1 ;; esac
[ -L "$root" ] && return 1
command -v gstack_path_within >/dev/null 2>&1 && gstack_path_within "$src" "$root" || return 1
if [ -L "$claude_link" ] && gstack_path_same "$claude_link" "$src"; then
@@ -182,8 +184,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
}
+16
View File
@@ -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 <<EOF
$(GSTACK_STATE_ROOT="$RESOLVED_ROOT"; . "$(dirname "$0")/gstack-harness-ledger.sh" && gstack_harness_unregister_all)
EOF
fi
# ─── Remove global Claude skills ────────────────────────────
CLAUDE_SKILLS="$HOME/.claude/skills"
+23 -10
View File
@@ -135,9 +135,11 @@ generator's exact `openai.yaml` shape, byte identity with the checkout) is
moved to `$GSTACK_STATE_ROOT/backups/host-renders/<ts>-<id>/`; every other file
stays where it is, and only directories left empty are removed. The backup's
`prune.log` lists every path. A gstack-upgrade migration runs
the same prune for an install upgraded without a setup run. Instruction-only
hosts (Hermes, OpenClaw, GBrain) are never installed by setup, so a user who
renders one by hand inside an install adds its name to the record first.
the same prune for an install upgraded without a setup run. Harness hosts
(OpenClaw, Hermes) render into a scratch directory and copy into the harness's
own folder, so they never add a render to the checkout; GBrain is never
installed by setup, so a user who renders it by hand inside an install adds its
name to the record first.
## Host tiers and capabilities
@@ -149,7 +151,7 @@ rows, host errors, and the README host matrix, all checked against
| Tier | Meaning |
|---|---|
| `full` | Installs, renders, passes the conformance kit, and has a dated certification record below. |
| `experimental` | Installs and renders and passes the conformance kit; no certification record yet. |
| `experimental` | Installs and renders and passes the conformance kit; no certification record yet. A harness host (`install.registration: 'harness'`: OpenClaw, Hermes) installs through `gstack-harness register` and is certified by `test/harness-register.test.ts` instead of the per-directory kit. |
| `instruction-only` | No install arm. `./setup --host X` prints what to copy and changes nothing. |
Rendering alone never certifies `full`.
@@ -275,13 +277,24 @@ root's extra markdown files (`ETHOS.md`, `review/*.md`) as skills, the
partial-read behavior of `view_file` on long skills, `ask_question` option
handling, and an upgrade from an existing install.
## Instruction-only hosts
## Instruction-only hosts and agent harnesses
OpenClaw, Hermes, Slate and GBrain have no install arm. Hermes stays
instruction-only until someone certifies a native install (#2826 measured one
on Hermes 0.21.0): `./setup --host hermes` prints the digest path and the
generator command, and the Hermes render writes `name: gstack-<skill>` so
Hermes, which indexes skills by frontmatter name, cannot shadow them (#2825).
Slate and GBrain have no install arm. OpenClaw and Hermes are harness hosts:
`./setup --host openclaw|hermes` (and `./setup` run inside either) calls
`bin/gstack-harness register`, which renders the host into a scratch
directory and copies it into the harness's own skills folder with an ownership
ledger (`lib/harness-register.ts`). The Hermes render writes `name:
gstack-<skill>` so Hermes, which indexes skills by frontmatter name, cannot
shadow them (#2825).
A harness that needs no render of its own (QM, dots, any other) is a row in
`TARGETS` in `lib/harness-register.ts`, not a host file: give it a mechanism,
a default destination, an inside-the-harness marker (an environment variable
the harness sets for the commands its agent runs, never a guess), a presence
check and, when it has one, the command that lists the skills it sees. Add its
reason codes to `HARNESS_CODES` with a `docs/troubleshooting.md` section, a
case to `test/harness-register.test.ts`, and a row to the table in
[hosts.md](hosts.md#agent-harnesses).
## How it works
+11 -3
View File
@@ -180,8 +180,16 @@ The agent should:
3. Add dispatch routing to AGENTS.md
4. Verify with a test spawn
For gstack developers: `./setup --host openclaw` outputs this documentation.
The actual artifacts are generated by `bun run gen:skill-docs --host openclaw`.
**Native skills or ACP.** `./setup --host openclaw` (or `./setup` run inside an
OpenClaw session) gives OpenClaw's own agent gstack's skills: one `gstack`
router skill in `$OPENCLAW_STATE_DIR/skills`, rendered with OpenClaw's tool
names and pointing at the installed gstack, so the agent runs gstack's
workflows and helpers itself. Use ACP when the work should happen in a Claude
Code session with the full install (hooks, Claude tool names); use native
skills when the OpenClaw agent should do it. Both can be installed together.
Install, verify and remove: [hosts.md](hosts.md#agent-harnesses).
The ACP artifacts in `openclaw/` are generated by `bun run gen:skill-docs --host openclaw`.
## What we don't do
@@ -190,4 +198,4 @@ The actual artifacts are generated by `bun run gen:skill-docs --host openclaw`.
- No bidirectional learnings bridge (brain repo is the knowledge store)
- No JSON schemas or protocol versioning
- No SOUL.md from gstack (OpenClaw has its own)
- No full skill porting (coding skills stay native to Claude Code)
- No hand-ported coding skills (native registration copies the generated OpenClaw render)
+3 -1
View File
@@ -261,7 +261,9 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
up with the same hosts, model and browser flag (recorded in the git dir
after a successful setup) and `gstack-doctor --check` passes; a warm rerun
takes about a second. Each setup ends with its own `gstack ready (<host>)`;
the installer then prints `gstack ready (claude, codex)`.
the installer then prints `gstack ready (claude, codex)`. Both name a
`--host`, so setup's agent-harness step ([hosts.md](hosts.md#agent-harnesses))
never runs on a Capy machine.
4. `gstack-doctor --check` for the requested workflow (`--for <skill>`,
`--only <skill>`, or `--require <id,id>`; default `claude,codex,patch`),
run with the launch PATH, so the trailer certifies the Bun workflows run.
+44 -2
View File
@@ -1,11 +1,53 @@
# Running gstack on other agents
The README has the host matrix: one row per agent with its tier, install
command and first invocation. This page has the rest: OpenClaw, how a checkout
command and first invocation. This page has the rest: agent harnesses (OpenClaw,
Hermes, dots, QM, Muse Code, Grok and any other), how a checkout
decides which agents it renders for, the per-host notes, outside-review
providers, the Codex behavioral profile, model tiers, and the design binary's
models.
## Agent harnesses
Installing gstack inside an agent harness registers its skills the harness's
own way, in the same `./setup` run. `bin/gstack-harness` does the work and
`lib/harness-register.ts` holds one target per harness.
**When it is automatic.** `./setup` with no `--host` (or `--host auto`)
registers with the harness it runs inside: OpenClaw (its exec tool sets
`OPENCLAW_SHELL=exec`), Hermes (`HERMES_AGENT=true`) or a Dot (`HOME=/home/dot`
on a Dot's computer), or a harness found among setup's parent processes. It
prints every path it wrote and the command that removes them. An explicit
`--host claude` (or any other host) never writes under a harness folder, and
neither does `./setup --local`.
`./setup --no-harness-auto` or `GSTACK_HARNESS_AUTO=0` turns the automatic step off.
**What it writes.** Copies in the harness's own skills folder, never edits to
`openclaw.json`, Hermes's `config.yaml` or any context file you did not name.
Every file goes into a ledger, `$GSTACK_STATE_ROOT/harness/<id>.ledger`, with
its sha256. Registering again changes only what changed; a folder or skill name
gstack did not write stops it (`HARNESS_UNOWNED_ENTRY`); `gstack-uninstall`
and `gstack-harness unregister` remove exactly the ledger's files, never
through a symlink, and rename any file you edited to `<file>.edited-<time>`
beside itself, where uninstall cannot take it. A context block is cut only
when it is the one gstack wrote; an edited block is left for you. `./setup
--refresh-registered` (what `/gstack-upgrade` runs) refreshes every registration.
`gstack-harness status` lists them and runs the harness's own skill list where
it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshooting.md#agent-harnesses-gstack-harness).
| Harness | What gstack registers | Install | Verify | Remove |
|---|---|---|---|---|
| OpenClaw | one `gstack` router skill (one description line in every request; its body lists every skill and its path) in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/gstack`; `--skills all` registers every skill as its own top-level folder | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` |
| Hermes | every skill, as `gstack-<skill>` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack` (Hermes opens only skills it has indexed) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` |
| dots (feder-cr) | the `gstack` router in `/home/dot/skills/gstack`, which the Dot lists every turn | `./setup` on the Dot's computer, or `gstack-harness register dots` | the Dot's Skills page, or `GET /skills` | `gstack-harness unregister dots` |
| QM | an org skill pack: `github.com/garrytan/gstack` at the installed commit, every skill imported (instruction-only there: discovery, no gstack runtime) | an org admin, with `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` set: `gstack-harness register qm --yes` | QM's skill list for the org | `gstack-harness unregister qm --remote` archives the pack; without `--remote` only the local record goes |
| Muse Code, Grok Build | nothing: both read `~/.claude/skills`, so the Claude install (`./setup`) is what they see, with Claude tool names | `./setup` | `gstack-harness status` runs `muse skills list` / `grok inspect` and reports *discovered (Claude-rendered, unverified)* | `gstack-uninstall` |
| Grok Bot, any chat-only skill library | nothing on disk | `gstack-harness register generic --print`, then paste the router and block into the app | ask it for a gstack skill | delete the pasted text |
| any other harness | the router in an Agent Skills folder you name, and/or a 600-byte routing block between `<!-- gstack:begin harness-routing -->` markers in a context file you name | `gstack-harness register generic --skills-dir <dir>` and/or `--context <file>`; `gstack-harness manifest generic` prints every skill as JSON | the harness's own skill list | `gstack-harness unregister generic` |
OpenClaw's ACP route below is unchanged: native registration gives OpenClaw's
own agent gstack's skills; ACP spawns Claude Code with the full install.
## OpenClaw
OpenClaw spawns Claude Code sessions via ACP, so every gstack skill just works
@@ -79,7 +121,7 @@ Outside reviews require the selected CLI to be installed and authenticated: Clau
## Instruction-only agents
**Instruction-only tier (any rules-reading agent — Zed, Amp, Jules, side projects):**
**Instruction-only tier (any rules-reading agent that loads no skills — Zed, Amp, Jules, side projects):**
copy the 2KB digest at [`agents-digest/gstack-AGENTS.md`](../agents-digest/gstack-AGENTS.md)
into a location your agent reads (for example, append it to your project's `AGENTS.md`).
It carries gstack's ethos, reuse ladder, and voice rules — no install required. The
+9 -1
View File
@@ -2,7 +2,7 @@
The README covers the two-step install. This page holds the details: what
setup needs and how long it takes, the team-mode hook, the hooks setup
registers in Claude Code, and how to remove gstack.
registers in Claude Code, agent harness registration, and how to remove gstack.
## How long setup takes
@@ -47,6 +47,14 @@ prune-stale --repoint` removes dead gstack hook entries, re-points stale ones
at the stable install, and collapses duplicates, printing one line (and
writing a backup beside the file) only when it changed something.
## Agent harnesses
Run inside OpenClaw, Hermes or a Dot, `./setup` also registers gstack's skills
with that harness, in the harness's own skills folder, and prints every path
it wrote. It never does this when `--host` names another host or with
`--local`. Turn it off with `./setup --no-harness-auto` or
`GSTACK_HARNESS_AUTO=0`. Per-harness details, QM and any other harness: [hosts.md](hosts.md#agent-harnesses).
## Uninstall
See [uninstall.md](uninstall.md).
+58
View File
@@ -5053,3 +5053,61 @@ a registered, unlocked worktree whose directory is already gone (`git worktree
prune`); a locked one, or a `broken` row whose `.git` link is missing, reads
`review` instead. `clean,ignored:<n>` means the tree is clean but holds
gitignored entries (`.env`, build output) that go with the worktree.
## Agent harnesses (gstack-harness)
`bin/gstack-harness` registers gstack's skills with the agent harness gstack is installed into (OpenClaw, Hermes, a Dot, QM, any other) and records every file it writes in `$GSTACK_STATE_ROOT/harness/<id>.ledger`. Each error line ends with its code and a link here. Per-harness install, verify and uninstall steps: [docs/hosts.md](hosts.md#agent-harnesses).
<a id="harness-unknown-target"></a>
### `gstack-harness: unknown harness "<name>"` (HARNESS_UNKNOWN_TARGET)
The name is not one of openclaw, hermes, dots, generic, qm, muse or grok. For a harness gstack does not know, use `generic` with `--skills-dir` or `--context`, or `--print` to get the router and block to paste in.
<a id="harness-no-destination"></a>
### `gstack-harness: <harness> has no default skills folder` (HARNESS_NO_DESTINATION)
`generic` has no default location, and gstack never guesses at your context files. Pass `--skills-dir <dir>` (an Agent Skills folder the harness reads) or `--context <file>` (a file it loads every session), or `--print` to write nothing.
<a id="harness-mode-unsupported"></a>
### `gstack-harness: Hermes does not support --skills router` (HARNESS_MODE_UNSUPPORTED)
Hermes opens only skills it has indexed, so a router that points at unlisted files would not load. Drop `--skills` to take the harness's default (Hermes: all; OpenClaw, dots and generic: router).
<a id="harness-unsafe-destination"></a>
### `gstack-harness: refusing to write into <dir>: <why>` (HARNESS_UNSAFE_DESTINATION)
The destination is your home folder, the filesystem root, inside the gstack checkout, a symlink (or holds one where gstack's own folders were), or a path with a tab or newline. Nothing was written. Pick the harness's own skills folder, for example `--skills-dir ~/.openclaw/skills`.
<a id="harness-unowned-entry"></a>
### `gstack-harness: <path> already exists and gstack did not write it` / `a skill named <name> already exists at <path>` (HARNESS_UNOWNED_ENTRY)
gstack only replaces files its ledger says it wrote. A folder or a skill with the same name is yours (or another tool's), and harnesses resolve skills by name, so gstack stops instead of shadowing it. Rename or move that entry, then register again.
The same code stops `register generic --context <file>` when the file holds a `<!-- gstack:begin harness-routing -->` or `<!-- gstack:end harness-routing -->` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again.
<a id="harness-render-failed"></a>
### `gstack-harness: could not render <harness> skills: ...` (HARNESS_RENDER_FAILED)
The harness's skills are rendered from this checkout with `scripts/gen-skill-docs.ts`. Run `bun install` in the checkout and register again; the last lines of the generator's output are in the message.
<a id="harness-budget-exceeded"></a>
### `gstack-harness: router description is <n> bytes` / `context block is <n> bytes` (HARNESS_BUDGET_EXCEEDED)
The router's description (at most 300 bytes) and the context block (at most 600 bytes) go into every session, so gstack refuses to grow them. This is a gstack bug: report it with the byte count.
<a id="harness-not-registered"></a>
### `gstack-harness: no registration named "<name>"` (HARNESS_NOT_REGISTERED)
There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `gstack-harness status` lists every registration on this machine with its id.
<a id="harness-qm-credentials"></a>
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
Registering gstack in QM publishes an org skill pack, so it needs an org admin's credential and an explicit `--yes`. Set `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` (or `GSTACK_QM_SIGNING_SECRET` with `GSTACK_QM_ADMIN_ACTOR`) in the environment and run `gstack-harness register qm --yes`. gstack never prompts for, prints or stores the credential; the message shows the exact request instead.
<a id="harness-qm-api"></a>
### `gstack-harness: <METHOD> /v1/admin/skill-packs... returned HTTP <code>` / `... failed: timed out after 60 s` (HARNESS_QM_API)
QM's admin API refused or did not answer. gstack lists packs before it creates one and retries once, so rerunning never makes a second pack. Check the URL is QM core and the credential is an org admin; `gstack-harness status` shows whether a pack id was recorded.
+3 -2
View File
@@ -8,7 +8,7 @@ If gstack is installed on your machine:
~/.claude/skills/gstack/bin/gstack-uninstall
```
This handles skills, symlinks, global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
## Option 2: Manual removal (no local repo)
@@ -49,7 +49,8 @@ rm -rf ~/.gstack
rm -rf "${CODEX_HOME:-$HOME/.codex}/skills/gstack"* 2>/dev/null
rm -rf ~/.factory/skills/gstack* 2>/dev/null
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
rm -rf ~/.openclaw/skills/gstack* 2>/dev/null
rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* 2>/dev/null
rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null
rm -rf ~/.cursor/skills/gstack* 2>/dev/null
rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null
rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null
+4 -4
View File
@@ -3,7 +3,7 @@ import { defineHost, CROSS_MODEL_RESOLVERS, preambleToolGlossary } from './defin
const hermes = defineHost({
name: 'hermes',
displayName: 'Hermes',
tier: 'instruction-only',
tier: 'experimental',
capabilities: { toolExecution: true, questions: 'native', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' },
// Hermes indexes skills by frontmatter name, first match wins (#2825).
@@ -43,11 +43,11 @@ const hermes = defineHost({
// If Hermes has GBrain as a mod, brain features activate automatically.
],
// No full install arm — users can hand-copy the instruction-only digest
// (setup's explainer arm prints this path; never auto-copied).
// ./setup --host hermes (and setup run inside Hermes) registers every skill
// through gstack-harness into $HERMES_HOME/skills/gstack (one category).
install: {
linkingStrategy: 'symlink-generated',
instructionTier: { rulesFile: 'agents-digest/gstack-AGENTS.md' },
registration: 'harness',
},
coAuthorTrailer: 'Co-Authored-By: Hermes Agent <agent@nousresearch.com>',
+5 -4
View File
@@ -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 <agent@openclaw.ai>',
+114
View File
@@ -0,0 +1,114 @@
/**
* QM (yc-software/qm) target for gstack-harness: gstack's skills become an org
* skill pack, the way QM itself imports skills (docs/skill-registry.md in the
* QM repository): an org admin registers a git repository at a pinned commit
* (POST /v1/admin/skill-packs) and imports it (POST /v1/admin/skill-packs/:id/import).
*
* Never automatic: it publishes skills to a whole organization, so it runs only
* as gstack-harness register qm --yes with an admin credential in the
* environment, and refuses with the exact request otherwise. Before creating a
* pack it lists the packs and reuses one with the same url and ref, so a retry
* after a partial failure never makes a second pack. The ledger records the
* pack id; unregister detaches the local record, and only --remote archives
* the org pack. Tokens are never printed or written.
*/
import { createHmac } from 'node:crypto';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { HarnessError, ledgerPath, readLedger, registrationId, writeLedger } from './harness-register';
type Env = Record<string, string | undefined>;
export const GSTACK_REPO_URL = 'https://github.com/garrytan/gstack';
/** Only the top-level skills: the committed renders at the repository root, not fixtures or the openclaw/ folder. */
export const QM_PACK_CONFIG = { skillGlobs: ['*/SKILL.md'] };
export interface QmAuth { url: string; headers: (method: string, pathname: string, body: string) => Record<string, string> }
export function qmAuth(env: Env): QmAuth | null {
const url = (env.GSTACK_QM_URL ?? '').replace(/\/+$/, '');
if (!url) return null;
if (env.GSTACK_QM_ADMIN_TOKEN) {
const token = env.GSTACK_QM_ADMIN_TOKEN;
return { url, headers: () => ({ authorization: 'Bearer ' + token }) };
}
if (env.GSTACK_QM_SIGNING_SECRET && env.GSTACK_QM_ADMIN_ACTOR) {
const secret = env.GSTACK_QM_SIGNING_SECRET; const actor = env.GSTACK_QM_ADMIN_ACTOR;
return {
url, headers: (method, pathname, body) => {
const ts = Math.floor(Date.now() / 1000);
const sig = 'v0=' + createHmac('sha256', secret).update('v0:' + ts + ':' + method + '\n' + pathname + '\n' + body).digest('hex');
return { 'x-timestamp': String(ts), 'x-signature': sig, 'x-admin-actor': actor };
},
};
}
return null;
}
async function call(auth: QmAuth, method: string, pathname: string, payload?: unknown): Promise<any> {
const body = payload === undefined ? '' : JSON.stringify(payload);
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 60_000);
try {
const res = await fetch(auth.url + pathname, {
method, signal: ctl.signal, body: method === 'GET' ? undefined : body,
headers: { 'content-type': 'application/json', ...auth.headers(method, pathname, body) },
});
const text = await res.text();
if (!res.ok) throw new HarnessError('HARNESS_QM_API', method + ' ' + pathname + ' returned HTTP ' + res.status + ': ' + text.slice(0, 200));
return text ? JSON.parse(text) : {};
} catch (e) {
if (e instanceof HarnessError) throw e;
throw new HarnessError('HARNESS_QM_API', method + ' ' + pathname + ' failed: ' + (ctl.signal.aborted ? 'timed out after 60 s' : String((e as Error).message ?? e)));
} finally { clearTimeout(timer); }
}
export function checkoutCommit(checkout: string): string {
const r = spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 });
return (r.stdout ?? '').trim();
}
/** The request an admin would send, printed when gstack cannot send it. */
export function qmRequest(ref: string): string {
return 'POST <QM core>/v1/admin/skill-packs ' + JSON.stringify({ url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })
+ '\nthen POST <QM core>/v1/admin/skill-packs/<pack id>/import {"selected":"all"}';
}
export interface QmResult { id: string; packId: string; ref: string; status: 'installed' | 'unchanged'; imported: unknown }
export async function registerQm(opts: { checkout: string; stateRoot: string; env: Env; version: string; yes: boolean }): Promise<QmResult> {
const ref = checkoutCommit(opts.checkout);
const auth = qmAuth(opts.env);
if (!auth || !opts.yes) {
throw new HarnessError('HARNESS_QM_CREDENTIALS', (auth ? 'registering a QM pack publishes skills to the whole organization, so it needs --yes' : 'no QM admin credential in the environment') + '. The request is:\n' + qmRequest(ref));
}
const id = registrationId('qm', auth.url);
const lfile = ledgerPath(opts.stateRoot, id);
const prev = readLedger(lfile);
const attempt = async () => {
const listed = await call(auth, 'GET', '/v1/admin/skill-packs');
const existing = (listed.packs ?? []).find((p: any) => p.url === GSTACK_REPO_URL && p.ref === ref);
const pack = existing ?? (await call(auth, 'POST', '/v1/admin/skill-packs', { url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })).pack;
if (!pack?.id) throw new HarnessError('HARNESS_QM_API', 'QM did not return a pack id');
writeLedger(lfile, { meta: { id, target: 'qm', dest: auth.url, mode: 'all', checkout: opts.checkout, version: opts.version, qm_pack: pack.id, qm_ref: ref }, files: [], dirs: [], blocks: [] });
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' });
return { pack, existing: !!existing, imported };
};
let out;
try { out = await attempt(); } catch (e) {
if (!(e instanceof HarnessError) || e.code !== 'HARNESS_QM_API') throw e;
out = await attempt();
}
return { id, packId: out.pack.id, ref, status: out.existing && prev?.meta.qm_pack === out.pack.id ? 'unchanged' : 'installed', imported: out.imported };
}
/** Archive the org pack (DELETE), then drop the local record. Without --remote, callers drop only the record. */
export async function removeQmPack(stateRoot: string, env: Env, id: string): Promise<string> {
const l = readLedger(ledgerPath(stateRoot, id));
if (!l?.meta.qm_pack) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no QM pack recorded under ' + id);
const auth = qmAuth(env);
if (!auth) throw new HarnessError('HARNESS_QM_CREDENTIALS', 'archiving the org pack needs the admin credential');
await call(auth, 'DELETE', '/v1/admin/skill-packs/' + encodeURIComponent(l.meta.qm_pack));
return l.meta.qm_pack;
}
export const qmLedgerFile = (stateRoot: string, env: Env) => env.GSTACK_QM_URL ? ledgerPath(stateRoot, registrationId('qm', env.GSTACK_QM_URL.replace(/\/+$/, ''))) : path.join(stateRoot, 'harness', 'qm-none.ledger');
+560
View File
@@ -0,0 +1,560 @@
/**
* gstack-harness: register gstack's skills with the agent harness gstack is
* installed into, the harness's own way, and take them back out (GSTA-135).
*
* Targets: openclaw and hermes (hosts with renders: real copies in the
* harness's own skills folder), dots (a router skill in the Dot's skills
* folder), generic (a router skill and/or a marked context block for any other
* harness), qm (an org skill pack through QM's admin API, lib/harness-qm.ts),
* muse and grok (detection only: both already read ~/.claude/skills).
*
* Every write is recorded in a ledger, $GSTACK_STATE_ROOT/harness/<id>.ledger,
* a TSV that bin/gstack-harness-ledger.sh reads without bun, so
* bin/gstack-uninstall removes exactly what was written before it removes the
* checkout. A file edited after registration is moved to a backup, never
* deleted; an entry gstack did not write is never overwritten.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation';
export type SkillsMode = 'router' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
type Env = Record<string, string | undefined>;
/** Every-session budgets (rule 30): the router's description and the context block. */
export const ROUTER_DESCRIPTION_MAX = 300;
export const CONTEXT_BLOCK_MAX = 600;
export const BLOCK_BEGIN = '<!-- gstack:begin harness-routing -->';
export const BLOCK_END = '<!-- gstack:end harness-routing -->';
/** 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 {
name: TargetName;
displayName: string;
mechanism: 'skill-folder' | 'skill-pack-api' | 'verify-only' | 'context-block';
/** Which skills the payload carries: the host's own render, or the checkout's committed Claude render. */
render: 'host' | 'claude' | null;
defaultMode: SkillsMode;
modes: SkillsMode[];
/** Destination folder for this target, from its environment, or null when it has no default. */
dest: (env: Env, home: string) => string | null;
/** Running inside this harness: a session marker or the harness among this process's ancestors. */
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, 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)));
/** An ancestor is the harness when its program, or the script its interpreter runs, is named for it (arguments never count). */
const ancestorIs = (ancestry: string[], name: string) => ancestry.some(id => {
const [exe, script = ''] = id.split(' ');
return exe === name || (/^(node|nodejs|bun|python[0-9.]*)$/.test(exe) && script.replace(/.(mjs|js|py)$/, '') === name);
});
export const TARGETS: Record<TargetName, Target> = {
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_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) }, 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_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)) }, 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: () => DOT_SKILLS_DIR,
inside: (env) => fs.existsSync('/opt/invisible-dots') && (env.HOME ?? '') === '/home/dot',
present: () => fs.existsSync('/opt/invisible-dots'),
},
generic: {
name: 'generic', displayName: 'any other harness', mechanism: 'context-block', render: 'claude', defaultMode: 'router', modes: ['router', 'all'],
dest: () => null,
inside: () => false,
present: () => false,
},
qm: {
name: 'qm', displayName: 'QM', mechanism: 'skill-pack-api', render: null, defaultMode: 'all', modes: ['all'],
dest: env => env.GSTACK_QM_URL || null,
inside: () => false,
present: () => false,
},
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: {}, 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: {}, listed: name => new RegExp('\\b' + name + '\\b') }),
},
};
/** Reason codes users can see; each has a section in docs/troubleshooting.md (test/harness-register.test.ts). */
export const HARNESS_CODES = {
HARNESS_UNKNOWN_TARGET: { anchor: 'harness-unknown-target', fix: 'name one of: openclaw, hermes, dots, generic, qm, muse, grok' },
HARNESS_NO_DESTINATION: { anchor: 'harness-no-destination', fix: 'pass --skills-dir <dir> or --context <file>, or --print to see the block without writing' },
HARNESS_MODE_UNSUPPORTED: { anchor: 'harness-mode-unsupported', fix: 'drop --skills, or pass a mode the target supports (Hermes opens only listed skills, so it takes all)' },
HARNESS_UNSAFE_DESTINATION: { anchor: 'harness-unsafe-destination', fix: 'pick a destination that is a plain folder outside the gstack checkout, not your home folder, with no tab or newline in its path' },
HARNESS_UNOWNED_ENTRY: { anchor: 'harness-unowned-entry', fix: 'move or rename the existing entry (gstack did not write it), then register again' },
HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' },
HARNESS_BUDGET_EXCEEDED: { anchor: 'harness-budget-exceeded', fix: 'this is a gstack bug: report it with the byte count printed above' },
HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' },
HARNESS_QM_CREDENTIALS: { anchor: 'harness-qm-credentials', fix: 'set GSTACK_QM_URL and either GSTACK_QM_ADMIN_TOKEN, or GSTACK_QM_SIGNING_SECRET with GSTACK_QM_ADMIN_ACTOR, then rerun with --yes' },
HARNESS_QM_API: { anchor: 'harness-qm-api', fix: 'check that the QM core URL is reachable and the credential is an org admin; gstack-harness status shows whether a pack was left half-registered' },
} as const;
export type HarnessCode = keyof typeof HARNESS_CODES;
export class HarnessError extends Error {
constructor(readonly code: HarnessCode, message: string) { super(message); }
line(): string {
const c = HARNESS_CODES[this.code];
return 'gstack-harness: ' + this.message + '. Fix: ' + c.fix + ' (' + this.code + '; docs/troubleshooting.md#' + c.anchor + ')';
}
}
export function getTarget(name: string): Target {
const t = (TARGETS as Record<string, Target>)[name];
if (!t) throw new HarnessError('HARNESS_UNKNOWN_TARGET', 'unknown harness ' + JSON.stringify(name));
return t;
}
/** This process's ancestors as "<program> <first argument>" basenames, nearest first (Linux /proc, else ps). */
export function processAncestry(pid = process.pid, depth = 16): string[] {
const out: string[] = [];
let cur = pid;
for (let i = 0; i < depth && cur > 1; i++) {
let ppid = 0; let cmd = '';
try {
const stat = fs.readFileSync('/proc/' + cur + '/stat', 'utf8');
ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
cmd = fs.readFileSync('/proc/' + cur + '/cmdline', 'utf8').split('\0').slice(0, 2).map(a => path.basename(a)).join(' ');
} catch {
const r = spawnSync('ps', ['-o', 'ppid=,command=', '-p', String(cur)], { encoding: 'utf8', timeout: 5000 });
const m = (r.stdout ?? '').trim().match(/^(\d+)\s+(.*)$/);
if (!m) break;
ppid = Number(m[1]); cmd = m[2].split(/\s+/).slice(0, 2).map(a => path.basename(a)).join(' ');
}
if (i > 0) out.push(cmd);
if (!ppid || ppid === cur) break;
cur = ppid;
}
return out;
}
export interface Detection { inside: TargetName[]; present: TargetName[] }
export function detect(env: Env = process.env, home = os.homedir(), ancestry = processAncestry()): Detection {
const names = Object.keys(TARGETS) as TargetName[];
return {
inside: names.filter(n => TARGETS[n].inside(env, ancestry)),
present: names.filter(n => TARGETS[n].present(env, home)),
};
}
// --- payload --------------------------------------------------------------
export interface SkillEntry { name: string; use: string; dir: string }
function frontmatterField(md: string, field: string): string {
const fm = md.startsWith('---\n') ? md.slice(4, md.indexOf('\n---', 4)) : '';
const lines = fm.split('\n');
const i = lines.findIndex(l => l.startsWith(field + ':'));
if (i < 0) return '';
const first = lines[i].slice(field.length + 1).trim();
if (first !== '|' && first !== '>') return first.replace(/^["']|["']$/g, '');
const body: string[] = [];
for (const l of lines.slice(i + 1)) { if (!/^\s/.test(l)) break; body.push(l.trim()); }
return body.join(' ');
}
/** First sentence of a description, without the trailing "(gstack)" tag, at most 160 characters. */
export function oneLineUse(description: string): string {
const d = description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
const s = (d.match(/^.*?[.!?](\s|$)/)?.[0] ?? d).trim();
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 => {
const md = path.join(root, name, 'SKILL.md');
if (!fs.existsSync(md)) return [];
const text = fs.readFileSync(md, 'utf8');
if (!text.includes('AUTO-GENERATED')) return [];
return [{ name: frontmatterField(text, 'name') || name, use: oneLineUse(frontmatterField(text, 'description')), dir: path.join(root, name) }];
});
}
/** The skills a target's payload carries: the host render (made for this checkout as the install root) or the checkout's committed Claude render. */
export function sourceSkills(target: Target, checkout: string, scratch: string): SkillEntry[] {
if (target.render === 'claude') return skillDirs(checkout).filter(s => s.name !== 'gstack');
const out = path.join(scratch, 'render');
fs.rmSync(out, { recursive: true, force: true });
const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'scripts/gen-skill-docs.ts', '--host', target.name, '--install-root', checkout, '--out-dir', out],
{ cwd: checkout, encoding: 'utf8', timeout: 300_000 });
if (r.status !== 0) throw new HarnessError('HARNESS_RENDER_FAILED', 'could not render ' + target.name + ' skills: ' + ((r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | ')));
const skills = skillDirs(path.join(out, '.' + target.name, 'skills'));
if (!skills.length) throw new HarnessError('HARNESS_RENDER_FAILED', 'the ' + target.name + ' render produced no skills');
return skills;
}
export function routerSkill(skills: Array<{ name: string; use: string; path: string }>, target: Target): string {
const claudeNote = target.render === 'claude'
? '\nThese files use Claude Code tool names (Bash, Read, Edit, Agent, AskUserQuestion); use your own equivalents.\n' : '';
return [
'---', 'name: gstack', 'description: ' + ROUTER_DESCRIPTION, '---',
'<!-- AUTO-GENERATED by gstack-harness register: rerun it instead of editing -->',
'# gstack skills', '',
'Pick the one skill whose line matches the task, then read that SKILL.md in full and follow it. Read nothing else up front.' + claudeNote,
...skills.map(s => '- **' + s.name + '**: ' + s.use + ' Read `' + s.path + '`'),
'',
].join('\n');
}
export function contextBlock(routerPath: string): string {
return [BLOCK_BEGIN, '## gstack skills',
"gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. For a coding, review, test, plan or release task, read `" + routerPath + '`, pick the one skill whose line matches, then read and follow that skill. Load nothing else up front.',
BLOCK_END].join('\n');
}
// --- ledger ----------------------------------------------------------------
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: Array<{ sha: string; path: string }> }
export const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
export const ledgerDir = (stateRoot: string) => path.join(stateRoot, 'harness');
export const registrationId = (target: string, dest: string) => target + '-' + sha256(dest).slice(0, 12);
export const ledgerPath = (stateRoot: string, id: string) => path.join(ledgerDir(stateRoot), id + '.ledger');
export function readLedger(file: string): Ledger | null {
if (!fs.existsSync(file)) return null;
const l: Ledger = { meta: {}, files: [], dirs: [], blocks: [] };
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const [kind, a, ...rest] = line.split('\t');
const b = rest.join('\t');
if (kind === 'meta') l.meta[a] = b;
else if (kind === 'file') l.files.push({ sha: a, path: b });
else if (kind === 'dir') l.dirs.push(b);
else if (kind === 'block') l.blocks.push({ sha: a, path: b });
}
return l;
}
export function writeLedger(file: string, l: Ledger): void {
const rows = ['# gstack-harness ledger v1 (read by bin/gstack-harness-ledger.sh)',
...Object.entries(l.meta).map(([k, v]) => 'meta\t' + k + '\t' + v),
...l.files.map(f => 'file\t' + f.sha + '\t' + f.path),
...l.dirs.map(d => 'dir\t-\t' + d),
...l.blocks.map(b => 'block\t' + b.sha + '\t' + b.path)];
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
fs.writeFileSync(file + '.tmp', rows.join('\n') + '\n', { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
}
export function listLedgers(stateRoot: string): Array<{ file: string; ledger: Ledger }> {
const dir = ledgerDir(stateRoot);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter(f => f.endsWith('.ledger')).sort()
.flatMap(f => { const ledger = readLedger(path.join(dir, f)); return ledger ? [{ file: path.join(dir, f), ledger }] : []; });
}
// --- register ----------------------------------------------------------------
export interface RegisterOptions {
target: Target;
mode?: SkillsMode;
/** Destination override: the harness skills folder (--skills-dir). */
skillsDir?: string;
/** Context file for the routing block (generic). */
context?: string;
checkout: string;
stateRoot: string;
env: Env;
home: string;
version: string;
/** Publish an installs.tsv row for host targets (setup's own arm publishes it instead). */
registry?: boolean;
}
export interface RegisterResult {
id: string; target: TargetName; dest: string; mode: SkillsMode;
status: 'installed' | 'updated' | 'unchanged'; written: number; skills: number; router: string | null; ledger: string; kept: string[];
}
const PLAIN = /^[^\t\n\r]+$/;
function assertSafeDest(dest: string, opts: RegisterOptions): void {
const bad = (why: string) => { throw new HarnessError('HARNESS_UNSAFE_DESTINATION', 'refusing to write into ' + dest + ': ' + why); };
if (!path.isAbsolute(dest) || !PLAIN.test(dest)) bad('not a plain absolute path');
const norm = path.resolve(dest);
if (norm === '/' || norm === path.resolve(opts.home)) bad('it is the filesystem root or your home folder');
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
const co = real(opts.checkout);
let probe = norm;
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) probe = path.dirname(probe);
const resolved = path.join(real(probe), path.relative(probe, norm));
if (resolved === co || resolved.startsWith(co + path.sep)) bad('it is inside the gstack checkout');
if (fs.existsSync(norm) && fs.lstatSync(norm).isSymbolicLink()) bad('it is a symlink');
}
function walkFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
const p = path.join(dir, e.name);
const st = fs.statSync(p);
return st.isDirectory() ? walkFiles(p) : st.isFile() ? [p] : [];
});
}
function resolveDest(opts: RegisterOptions, id?: string): string | null {
if (opts.skillsDir) return path.resolve(opts.skillsDir);
const d = opts.target.dest(opts.env, opts.home);
if (d) return d;
return opts.context && id ? path.join(ledgerDir(opts.stateRoot), id) : 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<string, Buffer>; router: string | null; names: string[] } {
const t = opts.target;
const skills = sourceSkills(t, opts.checkout, scratch);
const files = new Map<string, Buffer>();
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'), 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') {
for (const s of skills) {
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, names: skills.map(s => t.render === 'host' ? s.name : path.basename(s.dir)) };
}
const routerDir = path.join(dest, 'gstack');
const entries = skills.map(s => {
if (t.render !== 'host') return { name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') };
const to = path.join(routerDir, 'skills', path.basename(s.dir));
copyDir(s.dir, to);
return { name: s.name, use: s.use, path: path.join(to, 'SKILL.md') };
});
const router = path.join(routerDir, 'SKILL.md');
files.set(router, Buffer.from(routerSkill(entries, t)));
return { files, router, names: ['gstack', ...entries.map(e => e.name)] };
}
/** An edited file stays beside itself under a name no harness loads, so no uninstall step that removes gstack's folders can take it. */
function keepEdit(file: string, stamp: string): string {
const to = file + '.edited-' + stamp;
fs.renameSync(file, to);
return to;
}
/** Refuses a path outside dest, or one reached through a symlink below dest. */
function assertOwnedPath(dest: string, p: string): void {
if (!p.startsWith(dest + path.sep)) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + dest);
for (let d = p; d !== dest; d = path.dirname(d)) {
let st: fs.Stats;
try { st = fs.lstatSync(d); } catch { continue; }
if (st.isSymbolicLink()) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', d + ' is a symlink');
}
}
function onDiskSha(file: string): string | null {
try { return sha256(fs.readFileSync(file)); } catch { return null; }
}
/** The ledger's hash of a context block: its lines, each ending in a newline, as the bash reader's awk prints them. */
export const blockSha = (block: string) => sha256(block + '\n');
/**
* The text of file with the block in it: replaces the one gstack block there (whole-line markers) when it is the block
* gstack wrote (ownedSha) or already current, else appends. A lone or repeated marker, or a block someone
* edited, is refused so nothing outside gstack's own lines is ever rewritten.
*/
export function withBlock(file: string, block: string, ownedSha?: string): string {
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
const lines = text.split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
let next: string;
if (!bs.length && !es.length) next = text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block + '\n';
else if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
const cur = sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n');
if (cur !== blockSha(block) && cur !== ownedSha) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'the gstack block in ' + file + ' was edited or gstack did not write it; delete the lines from ' + BLOCK_BEGIN + ' to ' + BLOCK_END);
lines.splice(bs[0], es[0] - bs[0] + 1, ...block.split('\n'));
next = lines.join('\n');
} else throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
return next;
}
/** Runs fn from one of the checkout's sourced bash libraries, passing args through the environment. */
function sourcedBash(checkout: string, lib: string, fn: string, args: string[], stateRoot: string, env: Env) {
const argEnv = Object.fromEntries(args.map((a, i) => ['GSTACK_HARNESS_ARG' + i, a]));
return runBashSync('. "$GSTACK_HARNESS_LIB" && ' + fn + args.map((_, i) => ' "$GSTACK_HARNESS_ARG' + i + '"').join(''),
{ env: { ...process.env, ...env, ...argEnv, GSTACK_HARNESS_LIB: path.join(checkout, 'bin', lib), GSTACK_STATE_ROOT: stateRoot }, encoding: 'utf8', timeout: 60_000 });
}
/** Cuts a block this ledger recorded through the bash reader, so register and uninstall share one cutter. */
function cutBlock(opts: RegisterOptions, file: string, sha: string): void {
process.stdout.write(sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', '_gstack_harness_cut_block', [file, sha], opts.stateRoot, opts.env).stdout || '');
}
function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boolean {
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
}
export function register(opts: RegisterOptions): RegisterResult {
const t = opts.target;
if (t.mechanism === 'verify-only' || t.mechanism === 'skill-pack-api') throw new HarnessError('HARNESS_UNKNOWN_TARGET', t.name + ' is not registered through files (use status for ' + t.name + ', or lib/harness-qm.ts for qm)');
const mode = opts.mode ?? t.defaultMode;
if (!t.modes.includes(mode)) throw new HarnessError('HARNESS_MODE_UNSUPPORTED', t.displayName + ' does not support --skills ' + mode);
if (ROUTER_DESCRIPTION.length > ROUTER_DESCRIPTION_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'router description is ' + ROUTER_DESCRIPTION.length + ' bytes');
const provisional = opts.skillsDir ? path.resolve(opts.skillsDir) : t.dest(opts.env, opts.home) ?? (opts.context ? path.resolve(opts.context) : null);
if (!provisional) throw new HarnessError('HARNESS_NO_DESTINATION', t.displayName + ' has no default skills folder');
const id = registrationId(t.name, provisional);
const dest = resolveDest(opts, id)!;
assertSafeDest(dest, opts);
if (opts.context) assertSafeDest(path.resolve(opts.context), opts);
const lfile = ledgerPath(opts.stateRoot, id);
const prev = readLedger(lfile);
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-'));
try {
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));
for (const top of new Set([...files.keys()].map(topOf))) {
let st: fs.Stats;
try { st = fs.lstatSync(top); } catch { continue; }
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');
}
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p);
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
const ctx = opts.context ? path.resolve(opts.context) : '';
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');
const blocks = block ? [{ sha: blockSha(block), path: ctx }] : [];
const ctxText = block ? withBlock(ctx, block, prev?.blocks.find(b => b.path === ctx)?.sha) : null;
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
&& (prev.meta.context ?? '') === ctx && prev.blocks.every(b => blocks.some(n => n.path === b.path && n.sha === b.sha))
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const kept: string[] = [];
let written = 0;
if (!same) {
const dirs = new Set(prev?.dirs ?? []);
for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d);
const record = (recorded: typeof want) => writeLedger(lfile, {
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(ctx ? { context: ctx } : {}) },
files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks,
});
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]);
for (const b of prev?.blocks ?? []) if (b.path !== ctx) cutBlock(opts, b.path, b.sha);
for (const [p, sha] of prevFiles) {
if (files.has(p)) continue;
const disk = onDiskSha(p);
if (disk === sha) fs.rmSync(p, { force: true }); else if (disk) kept.push(keepEdit(p, stamp));
}
for (const [p, b] of files) {
const disk = onDiskSha(p);
if (disk === sha256(b)) continue;
if (disk && prevFiles.get(p) !== disk) kept.push(keepEdit(p, stamp));
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p + '.gstack-new', b);
fs.renameSync(p + '.gstack-new', p);
written++;
}
record(want);
}
if (ctxText !== null && ctxText !== (fs.existsSync(ctx) ? fs.readFileSync(ctx, 'utf8') : null)) {
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, ctxText);
}
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept };
} finally {
fs.rmSync(scratch, { recursive: true, force: true });
}
}
/** The router and context block for a harness gstack cannot write into (Grok Bot, a chat-only skill library). */
export function printPayload(checkout: string): string {
const t = TARGETS.generic;
const skills = sourceSkills(t, checkout, os.tmpdir()).map(s => ({ name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') }));
return [routerSkill(skills, t), contextBlock(path.join(checkout, 'SKILL.md'))].join('\n');
}
/** Remove one registration through the bash ledger reader (the same code gstack-uninstall runs). */
export function unregister(stateRoot: string, checkout: string, idOrTarget: string, env: Env = process.env): string[] {
const all = listLedgers(stateRoot).filter(l => l.ledger.meta.id === idOrTarget || l.ledger.meta.target === idOrTarget);
if (!all.length) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no registration named ' + JSON.stringify(idOrTarget));
for (const { file } of all) {
const r = sourcedBash(checkout, 'gstack-harness-ledger.sh', 'gstack_harness_unregister_ledger', [file], stateRoot, env);
process.stdout.write(r.stdout || '');
if (r.status !== 0) throw new HarnessError('HARNESS_NOT_REGISTERED', 'could not remove ' + file + ': ' + (r.stderr || '').trim());
}
return all.map(l => l.ledger.meta.id);
}
/** Re-register every file registration this checkout made (setup runs it on every pass, so upgrades refresh them). */
export function refresh(base: Omit<RegisterOptions, 'target'>, skip: Set<string> = new Set()): RegisterResult[] {
return listLedgers(base.stateRoot)
.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,
context: l.meta.context || undefined,
}));
}
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[] }
/** 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 || !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<string, Target>)[l.meta.target];
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 };
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.289",
"version": "1.91.290",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+6 -7
View File
@@ -125,12 +125,11 @@ export interface HostConfig {
/** How skills are linked into the host dir. */
linkingStrategy: 'real-dir-symlink' | 'symlink-generated';
/**
* Instruction-only fallback tier: a committed rules digest this host's
* users can hand-copy when there is no full install arm. Delivery is
* print-path + user-performed copy ONLY — setup must never write or
* overwrite a user's own AGENTS.md. See scripts/gen-agents-digest.ts.
* 'harness': setup's arm for this host is `gstack-harness register <host>`
* (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.
*/
instructionTier?: { rulesFile: string };
registration?: 'harness';
};
// --- Host-Specific Behavioral Config ---
@@ -189,8 +188,8 @@ 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.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)');
if (config.install.registration === 'harness' && config.tier !== 'experimental') {
errors.push('install.registration harness needs tier experimental (setup registers it through gstack-harness)');
}
const modelError = validateModel(config.defaultModel);
if (modelError) {
+46 -50
View File
@@ -23,8 +23,8 @@ Usage: ./setup [options]
Options:
--host <name> 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."
@@ -465,6 +428,7 @@ if [ "$REFRESH_REGISTERED" -eq 1 ] && [ -z "${GSTACK_SETUP_REFRESH_CHILD:-}" ];
_RF_TARGETS=""
_RF_SKIPPED=""
_rf_add() {
case "$1" in openclaw|hermes) return 0 ;; esac
case "
$_RF_TARGETS" in *"
$1 $2 $3
@@ -553,6 +517,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 +1381,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 +1418,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 +2324,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 +4799,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
+3 -23
View File
@@ -55,16 +55,9 @@ describe('agents-digest', () => {
test('setup never copies the digest onto a user AGENTS.md (print-path only)', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
// The explainer arms print the digest path, anchored to the script's own
// directory — a $(pwd)-relative path prints a nonexistent file whenever
// setup is invoked from anywhere but the repo root.
const explainer = setup.match(/^print_instruction_tier\(\) \{([\s\S]*?)^\}/m)?.[1];
expect(explainer).toBeDefined();
// Informational hosts resolve their own anchor before install preflight.
// Bind the printed path to that assignment, without fixing its local name.
const anchor = explainer!.match(/^\s*([A-Za-z_]\w*)="\$\(cd "\$\(dirname "\$0"\)" && pwd -P\)"$/m)?.[1];
expect(anchor).toBeDefined();
expect(explainer).toContain(`$${anchor}/${DIGEST_RELPATH}`);
// OpenClaw and Hermes register skills through gstack-harness now (GSTA-135),
// so no setup arm prints the digest; a $(pwd)-relative digest path would
// name a nonexistent file whenever setup runs from outside the repo root.
expect(setup).not.toMatch(/\$\(pwd\)\/agents-digest/);
// …and no line may write to an AGENTS.md destination. Covers direct
// write verbs (cp/ln/mv/tee/install/rsync/dd/truncate), > and >>
@@ -101,17 +94,4 @@ describe('agents-digest', () => {
expect(resolver).toContain(shared);
}
});
test('instruction-tier hosts declare the digest in host config — and the declaration tracks the real path', () => {
// This is what makes instructionTier.rulesFile load-bearing instead of
// config nobody reads: the declared string must equal the generator's
// DIGEST_RELPATH, and setup's explainer must print that same path. If
// the digest ever moves, all three surfaces fail together.
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
expect(setup).toContain(DIGEST_RELPATH);
for (const host of ['openclaw', 'hermes']) {
const src = fs.readFileSync(path.join(ROOT, 'hosts', `${host}.ts`), 'utf-8');
expect(src).toContain(`instructionTier: { rulesFile: '${DIGEST_RELPATH}' }`);
}
});
});
+2
View File
@@ -1,5 +1,7 @@
{
"newModules": [
{ "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" },
{ "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" },
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/render-probe.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-probe-space.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
+2 -2
View File
@@ -3689,11 +3689,11 @@ describe('setup script validation', () => {
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
});
test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => {
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|openclaw|hermes; slate is informational', () => {
expect(setupContent).toContain('--host');
// #2361: slate moved OUT of the install accept-list (it was accepted but
// never dispatched — a silent exit-0 no-op) into an informational arm.
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto');
expect(setupContent).toMatch(/^ {2}slate\)/m);
});
+356
View File
@@ -0,0 +1,356 @@
/**
* 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> = {}): 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<string, string> = {};
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('<a id="' + c.anchor + '"></a>');
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.dirname(r.router!));
expect(kept).toHaveLength(1);
expect(kept[0]).toMatch(/^SKILL\.md\.edited-/);
expect(fs.readFileSync(path.join(path.dirname(r.router!), kept[0]), 'utf8')).toEndWith('\nmy note\n');
}, 60_000);
test('generic: a lone, repeated or edited marker block is never cut or rewritten; prose that names a marker is left alone', () => {
const d = tmp();
const ctx = path.join(d, 'AGENTS.md');
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
const lone = '# mine\n' + BLOCK_BEGIN + '\n## Important\nkeep me\n';
fs.writeFileSync(ctx, lone);
expect(code(() => register(opts(d, 'generic', { context: ctx })))).toBe('HARNESS_UNOWNED_ENTRY');
expect(fs.readFileSync(ctx, 'utf8')).toBe(lone);
const prose = '# mine\nthe markers look like ' + BLOCK_BEGIN + ' and ' + BLOCK_END + '\n';
fs.writeFileSync(ctx, prose);
register(opts(d, 'generic', { context: ctx }));
unregister(path.join(d, 'state'), ROOT, 'generic');
expect(fs.readFileSync(ctx, 'utf8')).toBe(prose + '\n');
for (const edit of [(s: string) => s.replace(BLOCK_END + '\n', ''), (s: string) => s.replace('## gstack skills', '## gstack skills\nmy own line')]) {
fs.writeFileSync(ctx, '# mine\n');
register(opts(d, 'generic', { context: ctx }));
const edited = edit(fs.readFileSync(ctx, 'utf8')) + '## after\nkeep me too\n';
fs.writeFileSync(ctx, edited);
const un = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', path.join(ROOT, 'bin', 'gstack-harness-ledger.sh')], { env: { PATH: '/usr/bin:/bin', GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 30_000 });
expect(un.status, un.stderr).toBe(0);
expect(un.stdout).toContain('left ' + ctx + ' as it is');
expect(fs.readFileSync(ctx, 'utf8')).toBe(edited);
}
}, 60_000);
test('generic: adding or moving --context is recorded, and the old block is cut', () => {
const d = tmp();
const [a, b] = [path.join(d, 'a.md'), path.join(d, 'b.md')];
fs.writeFileSync(a, '# a\n');
fs.writeFileSync(b, '# b\n');
const skills = path.join(d, 'skills');
register(opts(d, 'dots', { skillsDir: skills }));
expect(register(opts(d, 'dots', { skillsDir: skills, context: a })).status).toBe('updated');
expect(fs.readFileSync(a, 'utf8')).toContain(BLOCK_BEGIN);
const r = register(opts(d, 'dots', { skillsDir: skills, context: b }));
expect(fs.readFileSync(a, 'utf8')).toBe('# a\n\n');
expect(readLedger(r.ledger)!.blocks.map(x => x.path)).toEqual([b]);
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.readFileSync(b, 'utf8')).toBe('# b\n\n');
}, 60_000);
test('a folder swapped for a symlink below the destination is never followed', () => {
const d = tmp();
const skills = path.join(d, 'skills');
const r = register(opts(d, 'openclaw', { skillsDir: skills }));
const review = path.join(skills, 'gstack', 'skills', fs.readdirSync(path.join(skills, 'gstack', 'skills')).sort()[0]);
const outside = path.join(d, 'outside');
fs.cpSync(review, outside, { recursive: true });
fs.rmSync(review, { recursive: true });
fs.symlinkSync(outside, review);
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
expect(code(() => register(opts(d, 'openclaw', { skillsDir: skills })))).toBe('HARNESS_UNSAFE_DESTINATION');
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.existsSync(path.join(outside, 'SKILL.md'))).toBe(true);
expect(fs.lstatSync(review).isSymbolicLink()).toBe(true);
}, 60_000);
});
describe('bin/gstack-harness-ledger.sh (the bun-free reader gstack-uninstall sources)', () => {
const ledgerSh = path.join(ROOT, 'bin', 'gstack-harness-ledger.sh');
test('removes every registration with only bash and coreutils on PATH, and refuses without a state root', () => {
const d = tmp();
const r = register(opts(d, 'dots', { skillsDir: path.join(d, 'skills') }));
const bare = { PATH: '/usr/bin:/bin', HOME: path.join(d, 'home') };
const refused = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', ledgerSh], { env: bare, encoding: 'utf8', timeout: 30_000 });
expect(refused.status).not.toBe(0);
expect(refused.stderr).toContain('GSTACK_STATE_ROOT is not set');
expect(fs.existsSync(r.router!)).toBe(true);
const ok = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', ledgerSh], { env: { ...bare, GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 30_000 });
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toContain('harness: ' + r.id);
expect(files(path.join(d, 'skills'))).toEqual([]);
expect(fs.existsSync(path.join(d, 'skills'))).toBe(false);
expect(listLedgers(path.join(d, 'state'))).toEqual([]);
}, 60_000);
test('a ledger with no destination row touches nothing and still finishes', () => {
const d = tmp();
const victim = path.join(d, 'mine.md');
fs.writeFileSync(victim, 'x');
const ledger = path.join(d, 'state', 'harness', 'broken.ledger');
fs.mkdirSync(path.dirname(ledger), { recursive: true });
fs.writeFileSync(ledger, 'meta\ttarget\tdots\nfile\t' + require('node:crypto').createHash('sha256').update('x').digest('hex') + '\t' + victim + '\n');
const r = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_ledger "$1"', ledgerSh, ledger], { env: { PATH: '/usr/bin:/bin', GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 10_000 });
expect(r.status, r.stderr).toBe(0);
expect(fs.readFileSync(victim, 'utf8')).toBe('x');
}, 30_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 status = runSetup(f, path.join(src, 'setup'), ['--status']);
expect(status.stdout).toMatch(new RegExp('\\b' + host + '\\s+experimental\\s+global\\s+current'));
expect(status.stdout).not.toContain('move it first');
fs.appendFileSync(path.join(f.home, dest, probe), '\nmy note\n');
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);
const left = files(path.join(f.home, dest)).filter(p => !p.startsWith('.'));
expect(left).toHaveLength(1);
expect(left[0]).toStartWith(probe + '.edited-');
expect(fs.readFileSync(path.join(f.home, dest, left[0]), 'utf8')).toEndWith('\nmy note\n');
}, 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);
});
+2 -1
View File
@@ -35,7 +35,8 @@ const DISCOVERY: Record<string, string> = {
/** Every directory any host (or a shared agents layout) reads; .gemini/skills is Gemini CLI's. */
const ALL_DISCOVERY_DIRS = [...Object.values(DISCOVERY), '.agents/skills', '.copilot/skills', '.hermes/skills', '.gemini/skills'];
const installable = ALL_HOST_CONFIGS.filter(c => c.tier !== 'instruction-only').map(c => c.name);
/** Harness hosts (install.registration 'harness') register through gstack-harness: test/harness-register.test.ts certifies them. */
const installable = ALL_HOST_CONFIGS.filter(c => c.tier !== 'instruction-only' && c.install.registration !== 'harness').map(c => c.name);
const instructionOnly = ALL_HOST_CONFIGS.filter(c => c.tier === 'instruction-only').map(c => c.name);
function skillNames(dir: string): string[] {
+1 -4
View File
@@ -118,7 +118,7 @@ describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
});
test.each(['slate', 'openclaw', 'hermes', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
test.each(['slate', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-info-preflight-'));
const startup = path.join(temporary, 'startup.bash');
fs.writeFileSync(startup, `command() { echo 'unexpected installation preflight' >&2; return 97; }
@@ -134,9 +134,6 @@ date() { echo 'unexpected backup timestamp' >&2; return 97; }
expect(res.status).toBe(0);
expect(res.stderr).toBe('');
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
if (host === 'openclaw' || host === 'hermes') {
expect(res.stdout.replaceAll('\\', '/')).toContain('/agents-digest/gstack-AGENTS.md');
}
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
});