v1.91.293.0 fix(harness): error lines print again; register qm stops at QM's pack cap instead of claiming QM works (GSTA-135) (#3532)

* fix(harness): print error lines again; register qm stops before QM's pack cap instead of claiming QM works

Bun gives every thrown Error an own numeric 'line' property, which shadowed
HarnessError.prototype.line(), so every refused gstack-harness command crashed
with 'e.line is not a function'. The printer is now report().

QM imports a skill pack by cloning the whole repository and refuses past
32 MiB / 5,000 files (Harness Evals preflight, HARN-5); gstack's repository is
73 MiB in 4,280 files. register qm now measures first and stops with
HARNESS_QM_PACK_TOO_LARGE before any request; QM's own cap refusal maps to the
same code. docs/hosts.md lists QM as not available yet.

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

* s

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

* fix(harness): read QM's recorded import error for the cap; refuse to size a non-git checkout; skip symlinks like QM

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

* chore: drop a stray a/SKILL.md committed by a scratch experiment

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

* docs: no doc implies QM registration works (document-release audit)

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

* docs(install): stable's 72-hour rule also excepts a regression fix (#3533)

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-28 10:16:23 +00:00
1 parent 219f7488bb
commit 9e3308f65a
11 files changed
+122 -18

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [1.91.293.0] - 2026-10-28
**`gstack-harness` errors print again, and `register qm` says plainly that QM cannot import gstack yet.** Every refused `gstack-harness` command crashed with `TypeError: e.line is not a function` instead of printing its reason, because Bun gives each thrown error its own numeric `line` property. Harness Evals then found that QM registration never worked: QM imports a skill pack by cloning the whole repository and stops at 32 MiB and 5,000 files, and gstack's repository is about 73 MiB in 4,300 files.
### Fixed
- **Error lines from `bin/gstack-harness`**: a refused command prints `gstack-harness: <reason>. Fix: <what to do> (<CODE>; docs/troubleshooting.md#<anchor>)` and exits 1, as documented. The printer is now `HarnessError.report()`.
- **`gstack-harness register qm --yes`** measures the repository at the installed commit first and stops with `HARNESS_QM_PACK_TOO_LARGE` before sending anything to QM. A QM that refuses with its own cap message gets the same code with QM's words, and no blind retry.
### Docs
- docs/hosts.md lists QM as not available yet, with the paste-in route (`gstack-harness register generic --print`) meanwhile; docs/troubleshooting.md has the `harness-qm-pack-too-large` section.
### Tests
- `test/harness-register.test.ts`: the CLI error path prints the code and fix with no TypeError; `register qm` against the real checkout refuses with zero requests and no ledger; a QM that answers `pack exceeds max bytes` gets the cap code; the pack reuse and retry case now runs against a small committed repository.
## [1.91.292.0] - 2026-10-28
**A fix for a regression on stable can skip the 72-hour wait, like a security fix.** Garry, asked whether non-security breakage on stable may bypass the twice-weekly cadence and the floor, answered on 2026-10-28: "Regression". Until now a fix for something the last stable release did right, and the current one breaks, waited up to 72 hours on every team install. Now `--regression "#n: worked in v<X>: why"` with `--ref` passes the floor once the tool ties it to the release, and the claim is public in the tag and the notes.
+1 -1
View File
@@ -1 +1 @@
1.91.292.0
1.91.293.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.292.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.293.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,
+4 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-harness — register gstack's skills with the agent harness it is
* installed into (OpenClaw, Hermes, dots, QM, any other), the harness's own
* installed into (OpenClaw, Hermes, dots, any other; QM once its pack cap allows), the harness's own
* way, and take them back out. lib/harness-register.ts holds the targets and
* the ledger; docs/hosts.md has the per-harness install, verify and uninstall
* blocks.
@@ -122,10 +122,10 @@ async function main(): Promise<number> {
const done = new Set<string>();
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); }
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
}
try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); }
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) {
console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n);
}
@@ -143,6 +143,6 @@ async function main(): Promise<number> {
}
main().then(code => process.exit(code), (e) => {
if (e instanceof HarnessError) { console.error(e.line()); process.exit(1); }
if (e instanceof HarnessError) { console.error(e.report()); process.exit(1); }
throw e;
});
+1 -1
View File
@@ -40,7 +40,7 @@ it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshootin
| OpenClaw | one `gstack` router skill (one description line in every request; its body lists every skill and its path) in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/gstack`; `--skills all` registers every skill as its own top-level folder | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` |
| Hermes | every skill, as `gstack-<skill>` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack` (Hermes opens only skills it has indexed) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` |
| dots (feder-cr) | the `gstack` router in `/home/dot/skills/gstack`, which the Dot lists every turn | `./setup` on the Dot's computer, or `gstack-harness register dots` | the Dot's Skills page, or `GET /skills` | `gstack-harness unregister dots` |
| QM | an org skill pack: `github.com/garrytan/gstack` at the installed commit, every skill imported (instruction-only there: discovery, no gstack runtime) | an org admin, with `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` set: `gstack-harness register qm --yes` | QM's skill list for the org | `gstack-harness unregister qm --remote` archives the pack; without `--remote` only the local record goes |
| 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 |
| Muse Code, Grok Build | nothing: both read `~/.claude/skills`, so the Claude install (`./setup`) is what they see, with Claude tool names | `./setup` | `gstack-harness status` runs `muse skills list` / `grok inspect` and reports *discovered (Claude-rendered, unverified)* | `gstack-uninstall` |
| Grok Bot, any chat-only skill library | nothing on disk | `gstack-harness register generic --print`, then paste the router and block into the app | ask it for a gstack skill | delete the pasted text |
| any other harness | the router in an Agent Skills folder you name, and/or a 600-byte routing block between `<!-- gstack:begin harness-routing -->` markers in a context file you name | `gstack-harness register generic --skills-dir <dir>` and/or `--context <file>`; `gstack-harness manifest generic` prints every skill as JSON | the harness's own skill list | `gstack-harness unregister generic` |
+2 -2
View File
@@ -18,7 +18,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel
From inside your repo, `(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required` switches you to team mode and bootstraps the repo so teammates get gstack automatically (the README has the full paste, which also commits the change).
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md).
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security or regression fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md).
Swap `required` for `optional` if you'd rather nudge teammates than block them.
@@ -53,7 +53,7 @@ Run inside OpenClaw, Hermes or a Dot, `./setup` also registers gstack's skills
with that harness, in the harness's own skills folder, and prints every path
it wrote. It never does this when `--host` names another host or with
`--local`. Turn it off with `./setup --no-harness-auto` or
`GSTACK_HARNESS_AUTO=0`. Per-harness details, QM and any other harness: [hosts.md](hosts.md#agent-harnesses).
`GSTACK_HARNESS_AUTO=0`. Per-harness details, any other harness, and why QM is not available yet: [hosts.md](hosts.md#agent-harnesses).
## Uninstall
+7 -2
View File
@@ -5070,7 +5070,7 @@ gitignored entries (`.env`, build output) that go with the worktree.
## Agent harnesses (gstack-harness)
`bin/gstack-harness` registers gstack's skills with the agent harness gstack is installed into (OpenClaw, Hermes, a Dot, QM, any other) and records every file it writes in `$GSTACK_STATE_ROOT/harness/<id>.ledger`. Each error line ends with its code and a link here. Per-harness install, verify and uninstall steps: [docs/hosts.md](hosts.md#agent-harnesses).
`bin/gstack-harness` registers gstack's skills with the agent harness gstack is installed into (OpenClaw, Hermes, a Dot, any other; QM is not available yet) and records every file it writes in `$GSTACK_STATE_ROOT/harness/<id>.ledger`. Each error line ends with its code and a link here. Per-harness install, verify and uninstall steps: [docs/hosts.md](hosts.md#agent-harnesses).
<a id="harness-unknown-target"></a>
### `gstack-harness: unknown harness "<name>"` (HARNESS_UNKNOWN_TARGET)
@@ -5117,10 +5117,15 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g
<a id="harness-qm-credentials"></a>
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
Registering gstack in QM publishes an org skill pack, so it needs an org admin's credential and an explicit `--yes`. Set `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` (or `GSTACK_QM_SIGNING_SECRET` with `GSTACK_QM_ADMIN_ACTOR`) in the environment and run `gstack-harness register qm --yes`. gstack never prompts for, prints or stores the credential; the message shows the exact request instead.
Registering gstack in QM publishes an org skill pack, so it needs an org admin's credential and an explicit `--yes`. Set `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` (or `GSTACK_QM_SIGNING_SECRET` with `GSTACK_QM_ADMIN_ACTOR`) in the environment and run `gstack-harness register qm --yes`. gstack never prompts for, prints or stores the credential; the message shows the exact request instead. With the credential set, registration still stops at the next check: QM cannot import gstack yet ([HARNESS_QM_PACK_TOO_LARGE](#harness-qm-pack-too-large)).
<a id="harness-qm-api"></a>
### `gstack-harness: <METHOD> /v1/admin/skill-packs... returned HTTP <code>` / `... failed: timed out after 60 s` (HARNESS_QM_API)
QM's admin API refused or did not answer. gstack lists packs before it creates one and retries once, so rerunning never makes a second pack. Check the URL is QM core and the credential is an org admin; `gstack-harness status` shows whether a pack id was recorded.
<a id="harness-qm-pack-too-large"></a>
### `gstack-harness: gstack at <sha> is <n> MiB in <n> files; nothing was sent to QM` / `QM refused the pack: pack exceeds max bytes (33554432)` (HARNESS_QM_PACK_TOO_LARGE)
QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions.
+25 -1
View File
@@ -21,6 +21,24 @@ type Env = Record<string, string | undefined>;
export const GSTACK_REPO_URL = 'https://github.com/garrytan/gstack';
/** Only the top-level skills: the committed renders at the repository root, not fixtures or the openclaw/ folder. */
export const QM_PACK_CONFIG = { skillGlobs: ['*/SKILL.md'] };
/** QM's git fetcher reads the whole tree before it applies skillGlobs, and refuses past these defaults (QM src/skills/pack-fetcher.ts). */
export const QM_PACK_LIMITS = { files: 5000, bytes: 32 * 1024 * 1024 };
const mib = (n: number) => (n / 1024 / 1024).toFixed(1) + ' MiB';
const tooLarge = (what: string) => new HarnessError('HARNESS_QM_PACK_TOO_LARGE', what + '. QM imports the whole repository and caps a pack at ' + mib(QM_PACK_LIMITS.bytes) + ' and ' + QM_PACK_LIMITS.files + ' files by default, so it cannot import gstack yet');
/** Files and bytes QM would read from the repository at ref. */
export function packSize(checkout: string, ref: string): { files: number; bytes: number } {
const r = spawnSync('git', ['-C', checkout, 'ls-tree', '-r', '-l', ref], { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0 || !ref) throw new HarnessError('HARNESS_QM_API', 'could not read the gstack checkout at ' + (ref || 'HEAD') + ' to size the pack (QM imports a git commit, so register qm needs a git checkout of gstack)');
let files = 0; let bytes = 0;
for (const line of (r.stdout ?? '').split('\n')) {
const [mode, type, , size] = line.split(/\s+/);
if (type !== 'blob' || mode === '120000' || !Number.isFinite(Number(size))) continue;
files++; bytes += Number(size);
}
return { files, bytes };
}
export interface QmAuth { url: string; headers: (method: string, pathname: string, body: string) => Record<string, string> }
@@ -81,6 +99,8 @@ export async function registerQm(opts: { checkout: string; stateRoot: string; en
if (!auth || !opts.yes) {
throw new HarnessError('HARNESS_QM_CREDENTIALS', (auth ? 'registering a QM pack publishes skills to the whole organization, so it needs --yes' : 'no QM admin credential in the environment') + '. The request is:\n' + qmRequest(ref));
}
const size = packSize(opts.checkout, ref);
if (size.files > QM_PACK_LIMITS.files || size.bytes > QM_PACK_LIMITS.bytes) throw tooLarge('gstack at ' + ref.slice(0, 12) + ' is ' + mib(size.bytes) + ' in ' + size.files + ' files; nothing was sent to QM');
const id = registrationId('qm', auth.url);
const lfile = ledgerPath(opts.stateRoot, id);
const prev = readLedger(lfile);
@@ -90,7 +110,11 @@ export async function registerQm(opts: { checkout: string; stateRoot: string; en
const pack = existing ?? (await call(auth, 'POST', '/v1/admin/skill-packs', { url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })).pack;
if (!pack?.id) throw new HarnessError('HARNESS_QM_API', 'QM did not return a pack id');
writeLedger(lfile, { meta: { id, target: 'qm', dest: auth.url, mode: 'all', checkout: opts.checkout, version: opts.version, qm_pack: pack.id, qm_ref: ref }, files: [], dirs: [], blocks: [] });
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' });
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' }).catch(async (e) => {
const recorded = ((await call(auth, 'GET', '/v1/admin/skill-packs').catch(() => ({}))).packs ?? []).find((p: any) => p.id === pack.id)?.lastImport;
const cap = /pack exceeds max (bytes|files) \(\d+\)/.exec(String(recorded?.error ?? ''));
throw cap ? tooLarge('QM refused the pack: ' + cap[0]) : e;
});
return { pack, existing: !!existing, imported };
};
let out;
+3 -1
View File
@@ -117,12 +117,14 @@ export const HARNESS_CODES = {
HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' },
HARNESS_QM_CREDENTIALS: { anchor: 'harness-qm-credentials', fix: 'set GSTACK_QM_URL and either GSTACK_QM_ADMIN_TOKEN, or GSTACK_QM_SIGNING_SECRET with GSTACK_QM_ADMIN_ACTOR, then rerun with --yes' },
HARNESS_QM_API: { anchor: 'harness-qm-api', fix: 'check that the QM core URL is reachable and the credential is an org admin; gstack-harness status shows whether a pack was left half-registered' },
HARNESS_QM_PACK_TOO_LARGE: { anchor: 'harness-qm-pack-too-large', fix: 'QM registration is not available yet; give a QM agent gstack with gstack-harness register generic --print pasted into its instructions' },
} as const;
export type HarnessCode = keyof typeof HARNESS_CODES;
export class HarnessError extends Error {
constructor(readonly code: HarnessCode, message: string) { super(message); }
line(): string {
/** Not named line(): Bun gives every thrown Error an own numeric line property, which would shadow it. */
report(): string {
const c = HARNESS_CODES[this.code];
return 'gstack-harness: ' + this.message + '. Fix: ' + c.fix + ' (' + this.code + '; docs/troubleshooting.md#' + c.anchor + ')';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.292",
"version": "1.91.293",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+63 -4
View File
@@ -13,7 +13,7 @@ import {
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
contextBlock, detect, listLedgers, readLedger, register, unregister, type RegisterOptions,
} from '../lib/harness-register';
import { qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
import { QM_PACK_LIMITS, qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -216,6 +216,18 @@ describe('gstack-harness register and unregister (skill folders)', () => {
}, 60_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();
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'unregister', 'nothing-here'],
{ env: { ...process.env, GSTACK_STATE_ROOT: path.join(d, 'state'), HOME: path.join(d, 'home') }, encoding: 'utf8', timeout: 60_000 });
expect(r.status, r.stderr).toBe(1);
expect(r.stderr).toContain('gstack-harness: no registration named "nothing-here"');
expect(r.stderr).toContain('(HARNESS_NOT_REGISTERED; docs/troubleshooting.md#harness-not-registered)');
expect(r.stderr).not.toContain('TypeError');
}, 60_000);
});
describe('bin/gstack-harness-ledger.sh (the bun-free reader gstack-uninstall sources)', () => {
const ledgerSh = path.join(ROOT, 'bin', 'gstack-harness-ledger.sh');
test('removes every registration with only bash and coreutils on PATH, and refuses without a state root', () => {
@@ -287,19 +299,66 @@ describe('gstack-harness QM (org skill pack, against a fake admin API)', () => {
expect(qmRequest('abc')).toContain('"ref":"abc"');
});
/** A small committed repository standing in for a pack under QM's cap. */
function smallCheckout(): string {
const dir = tmp();
const git = (...args: string[]) => spawnSync('git', ['-C', dir, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', timeout: 30_000 });
git('init', '-q');
fs.mkdirSync(path.join(dir, 'review'));
fs.writeFileSync(path.join(dir, 'review', 'SKILL.md'), '---\nname: review\ndescription: x\n---\nx\n');
git('add', '-A');
git('commit', '-qm', 'seed');
return dir;
}
test('refuses before sending anything when the repository is over QM\'s pack cap, and names the cap QM recorded when it refuses, without a retry', async () => {
const q = fakeQm();
try {
const d = tmp();
const err = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: q.env, version: VERSION, yes: true }).catch(e => e);
expect(err.code).toBe('HARNESS_QM_PACK_TOO_LARGE');
expect(err.message).toContain('nothing was sent to QM');
expect(err.message).toContain(String(QM_PACK_LIMITS.files));
expect(q.calls).toEqual([]);
expect(fs.existsSync(path.join(d, 'state', 'harness'))).toBe(false);
} finally { q.server.stop(true); }
const recorded: Array<Record<string, unknown>> = [];
const imports: string[] = [];
const capped = Bun.serve({ port: 0, fetch: async (req) => {
const u = new URL(req.url);
if (req.method === 'GET') return Response.json({ packs: recorded });
if (u.pathname.endsWith('/import')) {
imports.push(u.pathname);
recorded[0].lastImport = { status: 'error', error: 'pack exceeds max bytes (33554432)' };
return Response.json({ error: 'internal', message: 'internal server error' }, { status: 500 });
}
const b = await req.json() as Record<string, unknown>;
recorded.push({ id: 'pk1', url: b.url, ref: b.ref });
return Response.json({ pack: recorded[0] });
} });
try {
const d = tmp();
const err = await registerQm({ checkout: smallCheckout(), stateRoot: path.join(d, 'state'), env: { GSTACK_QM_URL: 'http://127.0.0.1:' + capped.port, GSTACK_QM_ADMIN_TOKEN: 't0k' }, version: VERSION, yes: true }).catch(e => e);
expect(err.code).toBe('HARNESS_QM_PACK_TOO_LARGE');
expect(err.message).toContain('pack exceeds max bytes (33554432)');
expect(imports).toHaveLength(1);
} finally { capped.stop(true); }
});
test('registers once, a retry after a failed import reuses the pack, two machines share it, --remote archives it', async () => {
const q = fakeQm(1);
const co = smallCheckout();
try {
const a = tmp(); const b = tmp();
const r1 = await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true });
const r1 = await registerQm({ checkout: co, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true });
expect(q.packs.length).toBe(1);
expect(q.calls.filter(c => c === 'POST /v1/admin/skill-packs').length).toBe(1);
expect(q.calls.filter(c => c.endsWith('/import')).length).toBe(2);
const ledger = fs.readFileSync(path.join(a, 'state', 'harness', r1.id + '.ledger'), 'utf8');
expect(ledger).toContain('qm_pack\tpk1');
expect(ledger).not.toContain('t0k');
expect((await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged');
await registerQm({ checkout: ROOT, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true });
expect((await registerQm({ checkout: co, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged');
await registerQm({ checkout: co, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true });
expect(q.packs.length).toBe(1);
expect(await removeQmPack(path.join(a, 'state'), q.env, r1.id)).toBe('pk1');
expect(q.packs.length).toBe(0);