train T2: merge #3610 at e30a52da4e

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 78223dcdee
6 files changed
+145 -10

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
**OpenClaw opens gstack's large task skills again (GST-110).** #3555 made `hybrid` OpenClaw's default and registered review, investigate, qa, ship and plan-eng-review as skills of their own. OpenClaw 2026.10.6 serves a registered skill's `SKILL.md` whole or refuses it, with a 32,768-byte limit when its model catalog has no context window for the model. In HARN-5's panel the agent's first read of the 77,691-byte `gstack-review` file was refused in 4 of 5 trials, and opens fell from 5/5 to 0/5. Under the router layout the same file had been read as a plain file inside the router's folder, which OpenClaw pages.
### Fixed
- **`gstack-harness register openclaw`** (`lib/harness-register.ts`): a skill whose rendered `SKILL.md` is over 32,000 bytes (`OPENCLAW_INSTRUCTION_MAX`, under both OpenClaw's 32,768-byte limit and the 32,000-character budget it applies with a 100K-200K-token context window, with the OpenClaw source lines it comes from) is registered as a short entry with the same frontmatter whose body says to read the skill's full `SKILL.md` in a plain copy of every skill and follow it. In hybrid that copy is the router's `gstack/skills/`, where the router layout read the same file, now including the five task skills; under `--skills all` it is `.gstack-skills/`, a folder OpenClaw's skill scan skips. Because the copy holds every skill, `sections/` paths and sibling paths such as `../gstack-qa/` and autoplan's `../gstack-plan-eng-review/SKILL.md` resolve to full texts. That covers four of hybrid's five direct skills and 26 of 63 skills in `--skills all` today. OpenClaw treats only a registered skill folder's `SKILL.md` as an instruction file. The ledger records the copies, so refresh, mode switches, upgrades from a #3555 registration and uninstall handle them. Skill bodies are unchanged, and Hermes, dots and `generic` registrations are byte-identical.
- docs/hosts.md and docs/OPENCLAW.md describe the entry and the limit, and docs/uninstall.md's manual OpenClaw removal command also removes `.gstack-skills/`.
### Tests
- `test/harness-register.test.ts`: in hybrid and all, every `SKILL.md` OpenClaw would register is within the limit, each entry names a full `SKILL.md` that exists, has the same frontmatter, is outside OpenClaw's registered set and is in the ledger, every `../gstack-*` path in the copied texts resolves to a full text, re-registering over a #3555 registration converts it cleanly, and uninstall leaves nothing. Hermes's registration equals its render byte for byte. With `GSTACK_OPENCLAW_BIN` or `openclaw` on PATH, a real `openclaw skills list --json` and `skills info` show exactly `gstack` and the five task skills, each served from an entry within the limit.
## [Unreleased]
**A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments).
### Added
+6
View File
@@ -188,6 +188,12 @@ with OpenClaw's tool names and pointing at the installed gstack, so the agent ru
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.
OpenClaw serves a registered skill's `SKILL.md` whole or refuses it, and its
limit is 32,768 bytes when it does not know the model's context window
(32,000 characters with a 100K-200K-token one), so a skill over 32,000 bytes (review, investigate, ship and plan-eng-review among the five) is
registered as a short entry that tells the agent to read the skill's full
`SKILL.md` in `gstack/skills/`, a plain copy of every skill that OpenClaw
reads in pages (`.gstack-skills/` under `--skills all`).
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`.
+1 -1
View File
@@ -59,7 +59,7 @@ characters, the part of a description Hermes's skills index shows.
| Harness | What gstack registers | Install | Verify | Remove |
|---|---|---|---|---|
| OpenClaw | default `--skills hybrid`: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` as top-level skills plus one `gstack` router skill whose body lists every other skill and its path, in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills`. With OpenClaw 2026.10.6's 19 eligible bundled skills, hybrid's skills section is 10,290 characters, full format, under its 18,000-character budget; `--skills all` (17,971, compact format, descriptions cut) lists every skill as its own folder, `--skills router` (6,701) only the router | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` and the five task skills (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` |
| OpenClaw | default `--skills hybrid`: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` as top-level skills plus one `gstack` router skill whose body lists every other skill and its path, in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills`. With OpenClaw 2026.10.6's 19 eligible bundled skills, hybrid's skills section is 10,290 characters, full format, under its 18,000-character budget; `--skills all` (17,971, compact format, descriptions cut) lists every skill as its own folder, `--skills router` (6,701) only the router. OpenClaw serves a registered skill's `SKILL.md` whole or refuses it ("Skill instructions cannot be partially served"), at most 32,768 bytes when its model catalog has no context window for the model and 32,000 characters with a 100K-200K-token window, so any registered skill over 32,000 bytes (four of the five in hybrid, 26 of 63 in `all` today) is a short entry `SKILL.md` with the same name and description that sends the agent to its full `SKILL.md` in a plain copy of every skill (the router's `gstack/skills/`, or `.gstack-skills/` under `--skills all`, a folder OpenClaw's skill scan skips), which OpenClaw reads in pages. A context window under 100K tokens lowers that budget to 16,000 characters, which this limit does not cover | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` and the five task skills (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's skills index lists only skills registered as folders, so a router would leave the rest out of the listing (`skill_view` still loads an unlisted skill by name, but nothing tells the model it exists) | `./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 | **not available yet.** QM imports a skill pack by cloning the whole repository and caps it at 32 MiB and 5,000 files; gstack's repository is about 73 MiB, so `gstack-harness register qm --yes` stops before sending anything ([why](troubleshooting.md#harness-qm-pack-too-large)) | meanwhile, paste `gstack-harness register generic --print` into the agent's instructions | ask it for a gstack skill | delete the pasted text |
+1 -1
View File
@@ -49,7 +49,7 @@ 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_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* 2>/dev/null
rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills" 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
+37 -4
View File
@@ -37,6 +37,19 @@ export const DOT_SKILLS_DIR = '/home/dot/skills';
export const ROUTER_DESCRIPTION = 'Use to review a diff or PR, debug a bug or failing test, QA test a site, plan or review architecture, ship a PR, audit security or write release docs. Load first for any coding task: lists every gstack skill and where to read it.';
/** OpenClaw hybrid mode: these task skills are listed as skills of their own; the router carries the rest. */
export const HYBRID_SKILLS = ['review', 'investigate', 'qa', 'ship', 'plan-eng-review'];
/**
* OpenClaw serves a registered skill's SKILL.md whole or refuses it ("Skill instructions cannot be partially
* served"), with maxBytes from resolveAdaptiveReadMaxBytes: 32,768 (DEFAULT_READ_PAGE_MAX_BYTES) when the model's
* context window is unknown (OpenClaw 2026.10.6 dist/agent-tools.read-BIxUGJy-.mjs:168, :189-194, :747, :798,
* :819-821). A known window also applies the tool-result budget to that read (resolveToolResultBudget,
* dist/tool-result-limits-DNBSu_jg.mjs:61-95): 16,000 characters under 100K tokens, 32,000 to 200K, 64,000 above.
* 32,000 bytes fits the unknown-window limit and a 100K-200K window; windows under 100K tokens are not covered. The
* post-delivery cap defaults to a 200K window (64,000 characters). A larger skill is registered as a short
* entry that points at its full SKILL.md in a plain tree of every skill, which OpenClaw reads in pages.
*/
export const OPENCLAW_INSTRUCTION_MAX = 32_000;
/** The plain tree under --skills all: a dot folder, which OpenClaw's skill scan skips (dist/skill-root-discovery-C2QVZC77.mjs:44). Hybrid and router use the router's own gstack/skills, which the scan never enters because gstack/ holds a SKILL.md (:343). */
export const OPENCLAW_ALL_TEXTS = '.gstack-skills';
export interface Target {
name: TargetName;
@@ -46,6 +59,8 @@ export interface Target {
render: 'host' | 'claude' | null;
defaultMode: SkillsMode;
modes: SkillsMode[];
/** Largest SKILL.md the harness serves whole; a larger skill is registered as an entry pointing at its full text. */
instructionMax?: number;
/** 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. */
@@ -65,7 +80,7 @@ const ancestorIs = (ancestry: string[], name: string) => ancestry.some(id => {
export const TARGETS: Record<TargetName, Target> = {
openclaw: {
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'],
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'], instructionMax: OPENCLAW_INSTRUCTION_MAX,
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')),
@@ -239,6 +254,19 @@ export function routerSkill(skills: Array<{ name: string; use: string; path: str
].join('\n');
}
/** A registered skill whose full text is over the harness's whole-document read limit: its own frontmatter, and a body that sends the agent to the full text. */
export function entrySkill(md: string, fullPath: string, target: Target): string {
const end = md.indexOf('\n---', 4);
return [
md.slice(0, md.indexOf('\n', end + 4) + 1) + '<!-- AUTO-GENERATED by gstack-harness register: rerun it instead of editing -->',
'# ' + frontmatterField(md, 'name'), '',
'This skill\'s full instructions (' + Buffer.byteLength(md) + ' bytes) are larger than ' + target.displayName + ' serves as a skill file, so they are in a plain file.'
+ ' Read `' + fullPath + '` in full now (every page, if the read is split) and follow it as this skill\'s instructions.'
+ ' Paths it gives relative to the installed SKILL.md directory resolve from that file\'s folder.',
'',
].join('\n');
}
/** The marked context block for the registration's mode: router (a router path), hybrid (a router path and direct skills) or all (no router). */
export function contextBlock(routerPath: string | null, direct: string[] = []): string {
const lead = "gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. ";
@@ -361,21 +389,26 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod
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'));
};
const routerDir = path.join(dest, 'gstack');
const plain = path.join(...(mode === 'all' ? [dest, OPENCLAW_ALL_TEXTS] : [routerDir, 'skills']));
const listed = (s: SkillEntry) => {
const to = path.join(dest, path.basename(s.dir));
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
const md = fs.readFileSync(path.join(s.dir, 'SKILL.md'));
if (t.instructionMax && md.length > t.instructionMax) files.set(path.join(to, 'SKILL.md'), Buffer.from(entrySkill(md.toString('utf8'), path.join(plain, path.basename(s.dir), 'SKILL.md'), t)));
else if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
return t.render === 'host' ? s.name : path.basename(s.dir);
};
if (mode === 'all') {
const names = skills.map(listed);
if (t.instructionMax) for (const s of skills) copyDir(s.dir, path.join(plain, path.basename(s.dir)));
return { files, router: null, visible: names, direct: [], skills: names.length };
}
const isDirect = (s: SkillEntry) => mode === 'hybrid' && HYBRID_SKILLS.includes(path.basename(s.dir).replace(/^gstack-/, ''));
const direct = skills.filter(isDirect).map(listed);
const routerDir = path.join(dest, 'gstack');
if (t.instructionMax) for (const s of skills.filter(isDirect)) copyDir(s.dir, path.join(plain, path.basename(s.dir)));
const entries = skills.filter(s => !isDirect(s)).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));
const to = path.join(plain, path.basename(s.dir));
copyDir(s.dir, to);
return { name: s.name, use: s.use, path: path.join(to, 'SKILL.md') };
});
+89 -4
View File
@@ -10,8 +10,8 @@ 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, HYBRID_SKILLS, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, unregister, type RegisterOptions,
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HYBRID_SKILLS, HarnessError, OPENCLAW_ALL_TEXTS, OPENCLAW_INSTRUCTION_MAX, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, sourceSkills, unregister, type RegisterOptions,
} from '../lib/harness-register';
import { QM_PACK_LIMITS, qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
@@ -89,7 +89,7 @@ describe('gstack-harness register and unregister (skill folders)', () => {
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]);
const names = fs.readdirSync(path.join(d, 'skills')).filter(n => !n.startsWith('.')).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());
@@ -269,7 +269,7 @@ describe('GSTA-135: registered skills get used', () => {
for (const mode of ['all', 'router', 'hybrid'] as const) {
expect(register(opts(d, 'openclaw', { env, mode })).status).toBe('updated');
expect(refresh(base).map(x => [x.mode, x.status])).toEqual([[mode, 'unchanged']]);
const top = fs.readdirSync(skills).filter(n => n !== 'mine');
const top = fs.readdirSync(skills).filter(n => n !== 'mine' && !n.startsWith('.'));
expect(top.length).toBe(mode === 'all' ? r.skills : mode === 'router' ? 1 : 1 + HYBRID_SKILLS.length);
}
unregister(path.join(d, 'state'), ROOT, r.id);
@@ -386,6 +386,91 @@ describe('GSTA-135: registered skills get used', () => {
}, 60_000);
});
describe('GST-110: OpenClaw serves every registered skill file whole', () => {
/** OpenClaw's registered instruction files: SKILL.md in each non-dot child folder, a folder without one searched below (dist/skill-root-discovery-C2QVZC77.mjs:44, :343-361). */
const registered = (dir: string, depth = 0): string[] => depth > 6 ? [] : fs.readdirSync(dir, { withFileTypes: true })
.filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
.flatMap(e => { const sub = path.join(dir, e.name); return fs.existsSync(path.join(sub, 'SKILL.md')) ? [path.join(sub, 'SKILL.md')] : registered(sub, depth + 1); });
const frontmatter = (md: string) => md.slice(0, md.indexOf('\n---', 4) + 4);
test('hybrid and all: every registered SKILL.md is within the limit; a larger skill is an entry naming its full SKILL.md in an unregistered tree of every skill, whose sibling paths resolve; uninstall removes both', () => {
const d = tmp();
for (const mode of ['hybrid', 'all'] as const) {
const skills = path.join(d, mode, 'skills');
const plain = mode === 'all' ? path.join(skills, OPENCLAW_ALL_TEXTS) : path.join(skills, 'gstack', 'skills');
const r = register(opts(d, 'openclaw', { mode, env: { OPENCLAW_STATE_DIR: path.join(d, mode) }, stateRoot: path.join(d, mode, 'state') }));
const mds = registered(skills);
expect(mds.length, mode).toBe(mode === 'all' ? r.skills : 1 + HYBRID_SKILLS.length);
const entries = mds.filter(md => fs.readFileSync(md, 'utf8').includes(' in full now (every page'));
if (mode === 'hybrid') expect(entries.map(md => path.basename(path.dirname(md)))).toContain('gstack-review');
expect(entries.length, mode).toBeGreaterThan(0);
for (const md of mds) expect(fs.statSync(md).size, md).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX);
const ledger = readLedger(r.ledger)!.files.map(x => x.path);
for (const md of entries) {
const entry = fs.readFileSync(md, 'utf8');
const full = /Read \x60([^\x60]+)\x60 in full/.exec(entry)![1];
expect(full).toBe(path.join(plain, path.basename(path.dirname(md)), 'SKILL.md'));
expect(mds).not.toContain(full);
expect(files(path.dirname(md))).toEqual(['SKILL.md']);
const text = fs.readFileSync(full, 'utf8');
expect(Buffer.byteLength(text)).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX);
expect(frontmatter(entry)).toBe(frontmatter(text));
expect(ledger).toContain(full);
}
for (const f of files(plain).filter(f => f.endsWith('.md'))) {
for (const [, ref] of fs.readFileSync(path.join(plain, f), 'utf8').matchAll(/\x60\.\.\/(gstack-[a-z-]+\/[^\x60\s]+)\x60/g)) {
expect(fs.existsSync(path.join(plain, ref)), f + ' -> ' + ref).toBe(true);
expect(fs.readFileSync(path.join(plain, ref), 'utf8'), ref).not.toContain(' in full now (every page');
}
}
unregister(path.join(d, mode, 'state'), ROOT, r.id);
expect(files(skills), mode).toEqual([]);
}
}, 180_000);
test('re-registering over a #3555 registration turns each large skill into an entry, removes its old files, and keeps nothing as an edit', () => {
const d = tmp();
const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') };
const old = register(opts(d, 'openclaw', { env, target: { ...TARGETS.openclaw, instructionMax: undefined } }));
expect(fs.statSync(path.join(old.dest, 'gstack-review', 'SKILL.md')).size).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX);
const r = register(opts(d, 'openclaw', { env }));
expect([r.id, r.status, r.kept]).toEqual([old.id, 'updated', []]);
expect(files(path.join(r.dest, 'gstack-review'))).toEqual(['SKILL.md']);
for (const md of registered(r.dest)) expect(fs.statSync(md).size, md).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX);
}, 120_000);
test('Hermes is unchanged: its registration is the Hermes render, byte for byte, with no entry files', () => {
const d = tmp();
const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } }));
const render = sourceSkills(TARGETS.hermes, ROOT, tmp());
const want = render.flatMap(s => files(s.dir).map(f => path.join(path.basename(s.dir), f))).sort();
expect(files(r.dest)).toEqual(want);
for (const s of render) for (const f of files(s.dir)) {
expect(fs.readFileSync(path.join(r.dest, path.basename(s.dir), f)).equals(fs.readFileSync(path.join(s.dir, f))), f).toBe(true);
}
}, 180_000);
const openclaw = process.env.GSTACK_OPENCLAW_BIN || Bun.which('openclaw');
test.skipIf(!openclaw)('a real OpenClaw lists the hybrid entries as its skills and none of the full texts (GSTACK_OPENCLAW_BIN or openclaw on PATH)', () => {
const d = tmp();
const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(d, 'oc') };
const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR } }));
const run = (...args: string[]) => {
const p = spawnSync(openclaw!, ['skills', ...args, '--json'], { cwd: d, env, encoding: 'utf8', timeout: 120_000 });
expect(p.status, p.stderr).toBe(0);
return JSON.parse(p.stdout);
};
const managed = run('list').skills.filter((s: { source: string }) => s.source === 'openclaw-managed').map((s: { name: string }) => s.name).sort();
expect(managed).toEqual(['gstack', ...HYBRID_SKILLS].sort());
for (const name of managed) {
const file = run('info', name).filePath as string;
expect(file.startsWith(r.dest + path.sep) && path.basename(file) === 'SKILL.md', file).toBe(true);
expect(fs.statSync(file).size, file).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX);
}
expect(fs.statSync(path.join(r.dest, 'gstack', 'skills', 'gstack-review', 'SKILL.md')).size).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX);
}, 180_000);
});
describe('bin/gstack-harness CLI error path', () => {
test('a refused command prints its reason code and fix, never a TypeError from the error printer', () => {
const d = tmp();