From b61744517cc35b3109f06a54d64857be27f60c4e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 07:25:11 -0700 Subject: [PATCH 1/2] v1.91.334.0 fix(harness): serve OpenClaw's large registered skills as entries pointing at a plain full copy (GST-110) (#3610) * fix(harness): register OpenClaw skills over its 32 KB whole-read limit as entries pointing at SKILL.full.md (GST-110) #3555's hybrid layout registered review, investigate, ship and plan-eng-review as OpenClaw skills; OpenClaw serves a registered SKILL.md whole or refuses it, at 32,768 bytes when the model's context window is unknown. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(harness): OpenClaw entry skills and the whole-read limit; CHANGELOG (GST-110) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-110) Entries point at a full SKILL.md in a plain copy of every skill (the router's gstack/skills/ in hybrid, .gstack-skills/ under --skills all), so autoplan's ../gstack-plan-eng-review/SKILL.md and other sibling paths reach full texts; test the #3555 upgrade path. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: remove .gstack-skills in the manual OpenClaw uninstall (GST-110) Step 14.5 audit gst110-docs-1: the gstack* glob never matched the dot folder that --skills all registrations now write. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(harness): OpenClaw entry threshold 32,000 bytes so served skills also fit a 100K-200K-token tool-result budget (GST-110) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.334.0 stamp 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> --- CHANGELOG.md | 11 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/OPENCLAW.md | 6 +++ docs/hosts.md | 2 +- docs/uninstall.md | 2 +- lib/harness-register.ts | 41 +++++++++++++-- package.json | 2 +- test/harness-register.test.ts | 93 ++++++++++++++++++++++++++++++++-- 9 files changed, 148 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92354d630..bad3b752e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.334.0] - 2026-10-30 + +**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.333.0] - 2026-10-30 **`gstack-factory-scorecard --now` now ends the window, so a past week no longer counts what happened after it.** Every line counts only items whose timestamps fall in `[now - since-days, now)`. Before, `--now` moved only the start, so `--now 2026-10-15T00:00:00Z --since-days 14` counted 290 merged PRs (every merge from 2026-10-01 through the day of the run) where the window holds 65, and the gate-run and release lines also counted through the day of the run. Rerun with the fix, the two weeks before 2026-10-15 show 65 merged PRs and the two weeks before 2026-10-29 show 197, matching GitHub search for both windows. diff --git a/VERSION b/VERSION index 131e70491..dcd0d47fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.333.0 +1.91.334.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f97901bab..1b7aff6a4 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.333.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.334.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, diff --git a/docs/OPENCLAW.md b/docs/OPENCLAW.md index d376b873a..e73bab35a 100644 --- a/docs/OPENCLAW.md +++ b/docs/OPENCLAW.md @@ -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`. diff --git a/docs/hosts.md b/docs/hosts.md index 8750cbbf1..1a18e0650 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -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-` 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 | diff --git a/docs/uninstall.md b/docs/uninstall.md index efadb2984..a0309dd7a 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -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 diff --git a/lib/harness-register.ts b/lib/harness-register.ts index bfc84a047..8ac054d77 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -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 = { 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) + '', + '# ' + 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') }; }); diff --git a/package.json b/package.json index 1ff4e87ba..bbe6565a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.333", + "version": "1.91.334", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/harness-register.test.ts b/test/harness-register.test.ts index 819da76fc..6544b11d1 100644 --- a/test/harness-register.test.ts +++ b/test/harness-register.test.ts @@ -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(); From 723b73ea897057a931c2576ca92f4ffe44fd8bc7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 09:18:15 -0700 Subject: [PATCH 2/2] v1.91.335.0 fix(capy): a session with no checkout reads the factory.md and preferences.yaml beside SKILL.md (GST-274) (#3643) * fix(capy): a session with no checkout reads the factory.md and preferences.yaml beside SKILL.md (GST-274) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy): a for=none session reads preferences.yaml even when an old checkout is on the machine (GST-274 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.335.0 stamp 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> --- CHANGELOG.md | 5 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/SKILL.md | 2 +- docs/model-policy.md | 7 +++++ package.json | 2 +- test/capy-skill-wrapper.test.ts | 51 +++++++++++++++++++++++++++++++++ 7 files changed, 67 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad3b752e..86e6c865a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.91.335.0] - 2026-10-30 + +### Fixed +- **Captains now read factory.md and the owner's model preference.** A Capy session with no gstack checkout (every Captain and coordinator, which run `install.sh --check --for none`) used to skip factory.md without a word and never applied `agent_model`, because both were read only from the checkout. The skill now tells it to read the `factory.md` beside SKILL.md, to say `factory.md: none` on its own line when there isn't one, and to read the `agent_model` line of `preferences.yaml` itself. Owners get the fix by copying `capy/skill/gstack/` into the Drive skill folder. + ## [1.91.334.0] - 2026-10-30 **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. diff --git a/VERSION b/VERSION index dcd0d47fd..f1dc51fa0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.334.0 +1.91.335.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1b7aff6a4..0f1c8614e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.334.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.335.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, diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 11bd23cc7..2e74284b9 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -9,7 +9,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper ()` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). -1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. After `gstack: ok`, read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) once per session, if present: how crews work, with pointers to the rules. +1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. Then, whatever the trailer says, once per session read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) when there is one, otherwise the `factory.md` beside this SKILL.md: how crews work, with pointers to the rules. If neither exists, report `factory.md: none` on its own line after the verbatim trailer. With no checkout, or after a `for=none` trailer (no launch file names the preference then), also read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data: a family such as `opus` means the newest model of that family in your model list, `provider/model` means that model, and `host`, no file or anything else (an unparseable value, two `agent_model` lines) means keep your own model and say so in your report. Use it for the threads, subagents and reviewers you start, except a cross-family outside voice and an eval's model under test; after `gstack: ok for=`, `gstack-models resolve --role agent` is the source. 2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor= ()` or `behind= (latest: )` with a `fix: ` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational ( commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped ()`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. 3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for ` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests. diff --git a/docs/model-policy.md b/docs/model-policy.md index 5ae20d113..e3a6612c3 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -163,6 +163,13 @@ good, edit or delete that line; `gstack-config unset agent_model` clears this machine only. Copying a newer `capy/skill/gstack/` over the skill folder leaves `preferences.yaml` alone, because the shipped folder has no such file. +A Capy session with no gstack checkout (a Captain or coordinator that runs +`install.sh --check --for none`) can't run the resolver, so the skill tells it +to read the `agent_model` line itself. There a family means the newest model of +that family the host lists, while the resolver maps each family to the id in +its table (today they agree: `opus` is `anthropic/claude-opus-5-5`). Anything +it can't use means the session keeps its own model and says so. + ## Reasoning effort Codex calls carry a `model_reasoning_effort`. It follows the same shape as the diff --git a/package.json b/package.json index bbe6565a4..3b490b635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.334", + "version": "1.91.335", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 4f0078ba7..90ff0882c 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { MODEL_FAMILIES } from '../lib/agent-model'; import { capyBootstrapCommand } from '../lib/doctor-components'; import { RESULT_CODES } from '../lib/result-codes'; import { capyHost, type CapyHost } from './helpers/capy-host'; @@ -535,4 +536,54 @@ describe('capy skill: factory.md, the auto-loaded "how we work" page', () => { expect(sessionStart).toContain('read `capy/skill/gstack/factory.md` in the checkout'); expect(sessionStart).toMatch(/Never write the skill folder yourself; the one exception is its `preferences\.yaml`/); }); + + describe('a session with no checkout (--for none) still reads factory.md and the model preference', () => { + const skillText = () => fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8'); + const readSentence = (text: string) => { + const start = text.indexOf('once per session read `capy/skill/gstack/factory.md`'); + return start < 0 ? '' : text.slice(start, text.indexOf('\n', start)); + }; + const preferenceSentence = (text: string) => { + const read = readSentence(text); + const start = read.indexOf('With no checkout, or after a `for=none` trailer'); + return start < 0 ? '' : read.slice(start); + }; + const unknownFamilies = (sentence: string) => + [...sentence.matchAll(/`([a-z]+)`/g)].map(m => m[1]).filter(t => t !== 'host' && !MODEL_FAMILIES.some(f => f.family === t)); + + test('factory.md: the checkout copy, else the one beside SKILL.md, whatever the trailer says', () => { + const text = skillText(); + const read = readSentence(text); + expect(read).toContain('read `capy/skill/gstack/factory.md` in the checkout'); + expect(read).toContain('otherwise the `factory.md` beside this SKILL.md'); + expect(text).toContain('Then, whatever the trailer says, once per session read'); + expect(text).not.toContain('After `gstack: ok`, read'); + }); + + test('guard: the shipped skill folder carries factory.md next to SKILL.md, so the fallback resolves in every Drive copy', () => { + expect(fs.existsSync(path.join(SKILL, 'factory.md'))).toBe(true); + }); + + test('neither copy: the session reports factory.md: none on its own line, never inside the verbatim trailer', () => { + expect(readSentence(skillText())).toContain('If neither exists, report `factory.md: none` on its own line after the verbatim trailer.'); + }); + + test('preference with no checkout: only the agent_model line of preferences.yaml beside SKILL.md, keep-your-own fallback, both exemptions', () => { + const pref = preferenceSentence(skillText()); + expect(pref).toContain('read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data'); + expect(pref).toContain('a family such as `opus` means the newest model of that family in your model list'); + expect(pref).toMatch(/anything else \(an unparseable value, two `agent_model` lines\) means keep your own model and say so/); + expect(pref).toContain('cross-family outside voice'); + expect(pref).toContain("an eval's model under test"); + expect(pref).toContain('With no checkout, or after a `for=none` trailer'); + expect(pref).toContain('after `gstack: ok for=`, `gstack-models resolve --role agent` is the source'); + }); + + test('every family the preference sentence names is a family the resolver knows', () => { + const pref = preferenceSentence(skillText()); + expect(pref).not.toBe(''); + expect(unknownFamilies(pref)).toEqual([]); + expect(unknownFamilies(pref.replace('`opus`', '`opux`'))).toEqual(['opux']); + }); + }); });