mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
Merge origin/main 723b73e into gsta154-changelog-fragments; stamp v1.91.336.0 (gstack-restamp --next --pr 3594, train gate.train-98e1977e3e71-723b73ea8970-082cfa slot 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
35b3df4a81
12 files changed
+213
-15
No files matched your search
+17
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.91.336.0] - 2026-10-30
|
||||
|
||||
**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).
|
||||
|
||||
@@ -21,6 +21,22 @@
|
||||
- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump (open version claims do not apply to it), and the workflow's missing path filter.
|
||||
- `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch.
|
||||
|
||||
## [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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.333.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.336.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,
|
||||
|
||||
@@ -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 <n> (<path>)` 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 <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` 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 <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (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 (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` 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 <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` 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 <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (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 (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` 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 <revision> for=<skill>`, `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=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` 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 (<n> 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 <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), 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 (<reason>)`, 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 <skill>` (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.
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
@@ -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') };
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.333",
|
||||
"version": "1.91.336",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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 <revision> for=<skill>`, `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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user