screen #3610: merge e30a52da4e onto main 319ef500ca

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

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [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.
## [1.91.320.0] - 2026-10-29
**A branch that adds its own pregate lane now learns why it still fails, and `gstack-post pr-body` no longer keeps a stale copy of a section.** Pregate reads its policy from the base branch so a branch cannot loosen its own gate. A branch that had already declared a lane in its own `.gstack/pregate.json` was still told to declare it, and went round in a circle. Separately, a PR body that held `## What changed` twice (an old description pasted under the /ship body) kept the old copy every time /ship reposted.
+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();