v1.91.317.0 feat(harness): trigger-first skill descriptions, hybrid OpenClaw layout, opt-in --context block (GSTA-135) (#3555)

* test(harness): pin the GSTACK_SESSION_KIND=spawned marker and its readers (GSTA-135)

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

* feat(harness): trigger-first skill descriptions, hybrid OpenClaw layout, opt-in --context block (GSTA-135)

Router lines and Hermes leads open with the skill's triggers; OpenClaw
defaults to hybrid (five task skills plus the router); --context writes the
routing block only into a named file, refusing SOUL.md and any new Hermes
rules file that would outrank one Hermes loads now.

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

* docs(harness): skill-use layout, --context and HARNESS_CONTEXT_REFUSED (GSTA-135)

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-29 13:28:08 +00:00
1 parent c85dd0a799
commit 9f44f188d1
20 files changed
+772 -106

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [1.91.317.0] - 2026-10-29
**Registered gstack skills now say when to use them where the harness model reads them, and OpenClaw lists the task skills directly.** HARN-5's paid cells showed OpenClaw and Hermes listing gstack's skills while the model rarely opened them. Router lines kept only each skill's first sentence and dropped its "Use when asked to ..." trigger, and Hermes's skills index shows only the first 57 characters of a description, which for gstack was capability prose. This release changes only what gstack writes into its own skills folders. Use rates are not claimed yet: HARN-5's fixed panel measures them next.
### Changed
- **Router lines lead with the trigger** (`gstack-harness`, every router-mode target: OpenClaw, dots, `generic` and `--print`). Each line opens with the skill's own trigger sentence, or, when the description has none, `Use for:` and the skill's `triggers:` list, within 160 characters. The router's description now leads with task verbs (review, debug, QA test, plan, ship) in its first 200 characters, within the 300-character budget. The wording rule lives in `lib/skill-triggers.ts`.
- **Hermes descriptions open with a trigger list of at most 57 characters** (`frontmatter.descriptionLead` in `hosts/hermes.ts`), for example `Use for: review this pr, code review, check my diff.`, followed by the description without its own trigger sentence, which the lead repeats, so no Hermes render grows. Claude Code and every other host render their descriptions as before.
- **OpenClaw registers in `hybrid` mode by default**: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` are top-level skills and the `gstack` router carries the rest. Dumped through OpenClaw 2026.10.6's own `buildSkillSnapshot`/`prepareSkillsForPrompt` with its 19 eligible bundled skills, hybrid's skills section is 10,290 characters in full format (budget 18,000). `--skills all` falls to compact format at 17,971 characters, and `--skills router` is 6,701. `--skills router` and `--skills all` remain, and a mode switch now removes the empty folders the previous mode left behind.
- **`--context <file>` is opt-in for OpenClaw and Hermes too**: the block is written only into the file named, after a `gstack-harness: context block: <file> (<profile|project> scope)` line. Setup, upgrades and refresh never add one and do not re-add a block you deleted. A second `--context` file keeps the first, because the ledger keys blocks by file. The block text follows the mode (with `--skills all` it names no router path). Uninstall now restores each file byte for byte (the blank separator line, a missing final newline and a file gstack created are undone).
- `HARNESS_MODE_UNSUPPORTED`'s fix text and docs/hosts.md describe Hermes's listing, not its loader, and docs/hosts.md states registered, listed and used as separate facts.
### Fixed
- Re-registering an OpenClaw, Hermes or dots registration without `--context` (what setup and `gstack-harness auto` do) no longer cuts a block the user opted into.
### Added
- `HARNESS_CONTEXT_REFUSED`: `--context` refuses `SOUL.md` (in any letter case), and for Hermes a new rules file that would outrank rules Hermes loads now: a file of an earlier group (`.hermes.md`/`HERMES.md` before `AGENTS.md` before `CLAUDE.md`), an earlier name in the same folder (`.hermes.md` before `HERMES.md`, `AGENTS.override.md` before `AGENTS.md`), or a `.hermes.md`/`HERMES.md` below one in a parent folder, which Hermes takes as the nearest.
### Gate integrity
- `test/harness-register.test.ts`: three expectations change with the byte-for-byte restore and blocks keyed by file. The generic cut now expects the original bytes, without the extra newline. The second `--context` case now expects both blocks kept and both restored where it used to expect the first block cut. The OpenClaw router case passes `mode: 'router'` because the default is now hybrid. New cases cover router lines, Hermes leads (at most 57 characters), hybrid with mode switches through refresh and uninstall, context opt-in, refusal and scope, the CLI scope line, and Hermes's threat scanner on every block variant. The scanner case runs when `GSTACK_HERMES_SRC` names a Hermes checkout and is skipped otherwise. More new cases cover the same-folder and nearest-parent refusals, pre-blockpad ledgers, refresh keeping a block the user edited, `--json --context` output, and the Hermes check outside a git repository; new `test/skill-triggers.test.ts` covers the wording rule's fallbacks and cuts.
- `test/fixtures/skill-md-size-allowlist.json`: the Hermes pins for `gstack-autoplan` (108,982 to 108,891) and `gstack-plan-eng-review` (128,708 to 128,655) go down to the new render sizes, as the size guard directs.
- New `test/session-kind-marker.test.ts`: a marker-only environment (`GSTACK_SESSION_KIND=spawned`, no `GSTACK_CAPY`) gets the same soft admission from `bin/gstack-install-verdict.sh` and `lib/install-verdict.ts`. A ratchet fails when a file in `bin/`, `lib/`, `hosts/` or `scripts/` reads or sets `GSTACK_SESSION_KIND` outside its 13-file inventory; a planted-reader control and a comments-only control check it.
## [1.91.316.0] - 2026-10-29
**/ship takes a token cap and stops cleanly at it, and long runs hand their heavy segments to fresh-context children.** On gstack-evals#31 a /ship spent 54M tokens against a 25M cap because the cap was only a number in a brief; #26, whose agent read the counter after every step, stopped at 39.40M of 40M. `/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP`) now admits each step and dispatch batch only when it fits, and a run started from a long-lived context, or one whose per-call charge passes 150k at Step 7 or Step 12, runs that segment in a fresh child.
+1 -1
View File
@@ -78,7 +78,7 @@ installs and passes the conformance tests but has no certification run yet;
| GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` |
| Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` |
| Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` |
| OpenClaw | experimental | `--host openclaw` (one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the `gstack` skill | advisory, not blocked | — | `./setup --host openclaw` |
| OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` |
| Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` |
| GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — |
+1 -1
View File
@@ -1 +1 @@
1.91.316.0
1.91.317.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.316.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.317.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 -4
View File
@@ -8,7 +8,10 @@
*
* Usage:
* gstack-harness detect [--json]
* gstack-harness register <target> [--skills router|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
* gstack-harness register <target> [--skills router|hybrid|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
* --context <file> writes gstack's marked routing block into that file only
* (opt-in: setup, upgrades and refresh never add one, and a block you
* delete stays deleted); it prints the file and its scope before writing
* gstack-harness unregister <target|registration id> [--remote]
* gstack-harness status [--json] [--no-check]
* gstack-harness refresh
@@ -20,7 +23,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { resolveStateRoot } from '../lib/state-root';
import {
HarnessError, TARGETS, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
HarnessError, TARGETS, contextScope, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
type RegisterResult, type SkillsMode, type TargetName,
} from '../lib/harness-register';
import { registerQm, removeQmPack } from '../lib/harness-qm';
@@ -38,9 +41,10 @@ const self = path.join(checkout, 'bin', 'gstack-harness');
function receiptLines(r: RegisterResult): string[] {
const t = TARGETS[r.target];
const how = { router: 'reachable through one router skill', hybrid: 'registered, the task skills listed directly and the rest through one router skill', all: 'registered' }[r.mode];
return [
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []),
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []),
...r.kept.map(k => ' kept your edit: ' + k),
' ledger: ' + r.ledger,
' remove: ' + self + ' unregister ' + r.id,
@@ -83,6 +87,7 @@ async function main(): Promise<number> {
console.log('gstack-harness: ' + t.displayName + ': unverified (' + (seen.checked ? seen.checked + ' did not list gstack' : t.name + ' is not on PATH') + '). Install gstack for Claude Code (./setup), or paste the block from: ' + self + ' register generic --print');
return 3;
}
if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)');
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry });
if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') }));
else receipt(r);
+16 -7
View File
@@ -13,8 +13,11 @@
# uninstall of the state root and the checkout. Only paths below the recorded
# destination, reached through no symlink, are touched. A context block is cut
# only when its file holds exactly one whole-line begin/end marker pair whose
# lines hash to the ledger's block hash; otherwise the file is left as it is
# (the file is removed only when nothing else is left in it). Folders gstack
# lines hash to the ledger's block hash; otherwise the file is left as it is.
# Its blockpad row says what gstack added before the block, so the cut restores
# the file byte for byte: the blank line (and newline) it appended are removed
# too, and a file gstack created is removed when nothing else is left in it
# (ledgers without the row: when only whitespace is left). Folders gstack
# created are removed only when empty. A QM pack is never touched here: detaching it from
# the org is `gstack-harness unregister qm --remote`.
@@ -33,7 +36,7 @@ _gstack_harness_owned() {
}
_gstack_harness_cut_block() {
local file="$1" want="$2" span s e tmp
local file="$1" want="$2" pad="${3:-}" span s e tmp
local begin='<!-- gstack:begin harness-routing -->' end='<!-- gstack:end harness-routing -->'
{ [ -f "$file" ] && [ ! -L "$file" ]; } || return 0
span="$(awk -v B="$begin" -v E="$end" '$0 == B { nb++; s = NR } $0 == E { ne++; e = NR } END { if (nb == 1 && ne == 1 && e > s) print s, e; else if (nb || ne) print "bad" }' "$file")"
@@ -44,13 +47,18 @@ _gstack_harness_cut_block() {
return 0
fi
tmp="$file.gstack-cut.$$"
awk -v s="$s" -v e="$e" 'NR < s || NR > e' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$file"; else cat "$tmp" > "$file"; fi
awk -v s="$s" -v e="$e" -v pad="$pad" '{ line[NR] = $0 }
END {
from = s; if ((pad == "1" || pad == "2") && s > 1 && line[s - 1] == "") from = s - 1
n = 0; for (i = 1; i <= NR; i++) if (i < from || i > e) out[++n] = line[i]
for (i = 1; i <= n; i++) { printf "%s", out[i]; if (i < n || !(pad == "2" && e == NR)) printf "\n" }
}' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
if { [ "$pad" = new ] && [ ! -s "$tmp" ]; } || { [ -z "$pad" ] && [ -z "$(tr -d '[:space:]' < "$tmp")" ]; }; then rm -f "$file"; else cat "$tmp" > "$file"; fi
rm -f "$tmp"
}
gstack_harness_unregister_ledger() {
local ledger="$1" stamp kind a b target="" dest="" pack=""
local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath=""
: "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}"
[ -f "$ledger" ] || return 0
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
@@ -61,7 +69,8 @@ gstack_harness_unregister_ledger() {
{ [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;;
block) _gstack_harness_cut_block "$b" "$a" ;;
blockpad) pad="$a"; padpath="$b" ;;
block) if [ "$padpath" = "$b" ]; then _gstack_harness_cut_block "$b" "$a" "$pad"; else _gstack_harness_cut_block "$b" "$a"; fi ;;
esac
done < "$ledger"
while IFS=$'\t' read -r kind a b; do
+4 -3
View File
@@ -181,9 +181,10 @@ The agent should:
4. Verify with a test spawn
**Native skills or ACP.** `./setup --host openclaw` (or `./setup` run inside an
OpenClaw session) gives OpenClaw's own agent gstack's skills: one `gstack`
router skill in `$OPENCLAW_STATE_DIR/skills`, rendered with OpenClaw's tool
names and pointing at the installed gstack, so the agent runs gstack's
OpenClaw session) gives OpenClaw's own agent gstack's skills: the five task
skills (review, investigate, qa, ship, plan-eng-review) listed directly and one
`gstack` router skill for the rest in `$OPENCLAW_STATE_DIR/skills`, rendered
with OpenClaw's tool names and pointing at the installed gstack, so the agent runs gstack's
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.
+24 -2
View File
@@ -24,6 +24,14 @@ neither does `./setup --local`.
**What it writes.** Copies in the harness's own skills folder, never edits to
`openclaw.json`, Hermes's `config.yaml` or any context file you did not name.
A context block is opt-in: `gstack-harness register openclaw|hermes|generic
--context <file>` writes the marked block into that file only, after printing
the file and its scope (`profile` inside the harness's own home, else
`project`). Setup, upgrades and refresh never add one, a block you delete stays
deleted, a second `--context` file keeps the first (the ledger keys blocks by
file), and uninstall restores each file byte for byte. It refuses `SOUL.md`,
and for Hermes a new file that would outrank rules Hermes loads now
(`.hermes.md`/`HERMES.md` load before `AGENTS.md`): `HARNESS_CONTEXT_REFUSED`.
Every file goes into a ledger, `$GSTACK_STATE_ROOT/harness/<id>.ledger`, with
its sha256. Registering again changes only what changed; a folder or skill name
gstack did not write stops it (`HARNESS_UNOWNED_ENTRY`); `gstack-uninstall`
@@ -35,10 +43,24 @@ when it is the one gstack wrote; an edited block is left for you. `./setup
`gstack-harness status` lists them and runs the harness's own skill list where
it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshooting.md#agent-harnesses-gstack-harness).
**Registered, listed, used.** These are three separate facts, and this page
claims only the first two. *Registered*: gstack's files are in the harness's
skills folder and its ledger (`gstack-harness status`). *Listed*: the harness
puts the skill in the skills section of the model's prompt (`visible to
harness: yes`, from the harness's own skill list). *Used*: in a task that fits,
the model opens the matching skill and runs its first step. Use rates are
pending HARN-5's fixed panel (OpenClaw and Hermes chat mode, five trials per
cell). When they land they are measured under the eval adapter's
`GSTACK_SESSION_KIND=spawned` marker, which auto-decides gstack's questions, so
they say nothing yet about interactive sessions. Two levers aim at use:
each router line opens with the skill's own trigger sentence ("Use when asked
to ..."), and each Hermes description opens with a trigger list of at most 57
characters, the part of a description Hermes's skills index shows.
| Harness | What gstack registers | Install | Verify | Remove |
|---|---|---|---|---|
| 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` |
| 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` |
| 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 |
| 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` |
+7 -2
View File
@@ -5304,7 +5304,7 @@ The name is not one of openclaw, hermes, dots, generic, qm, muse or grok. For a
<a id="harness-mode-unsupported"></a>
### `gstack-harness: Hermes does not support --skills router` (HARNESS_MODE_UNSUPPORTED)
Hermes opens only skills it has indexed, so a router that points at unlisted files would not load. Drop `--skills` to take the harness's default (Hermes: all; OpenClaw, dots and generic: router).
Hermes lists in its skills index only the skills registered as folders in its skills tree, so a router would leave every other gstack skill out of the listing the model chooses from (Hermes's `skill_view` can still load an unlisted skill by name, but nothing tells the model it exists). Drop `--skills` to take the harness's default (Hermes: all; OpenClaw: hybrid; dots and generic: router). OpenClaw takes router, hybrid or all.
<a id="harness-unsafe-destination"></a>
### `gstack-harness: refusing to write into <dir>: <why>` (HARNESS_UNSAFE_DESTINATION)
@@ -5316,7 +5316,12 @@ The destination is your home folder, the filesystem root, inside the gstack chec
gstack only replaces files its ledger says it wrote. A folder or a skill with the same name is yours (or another tool's), and harnesses resolve skills by name, so gstack stops instead of shadowing it. Rename or move that entry, then register again.
The same code stops `register generic --context <file>` when the file holds a `<!-- gstack:begin harness-routing -->` or `<!-- gstack:end harness-routing -->` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again.
The same code stops `register <harness> --context <file>` when the file holds a `<!-- gstack:begin harness-routing -->` or `<!-- gstack:end harness-routing -->` line without exactly one partner, or a gstack block someone edited. Delete those marker lines (and the lines between them, once you have kept what you want), then register again.
<a id="harness-context-refused"></a>
### `gstack-harness: refusing to write the gstack block into <file>: <why>` (HARNESS_CONTEXT_REFUSED)
`--context` writes the routing block only into a file you name, and two kinds of file are refused before anything is written. `SOUL.md` (in any letter case) is the harness's identity file (Hermes and OpenClaw both load it every session), and gstack never edits it. For Hermes, a new `.hermes.md`, `HERMES.md`, `AGENTS.override.md`, `AGENTS.md` or `CLAUDE.md` that would outrank a rules file Hermes loads now is refused too: Hermes loads one project context type, first match wins (`.hermes.md`/`HERMES.md`, then the `AGENTS.md` chain, then `CLAUDE.md`, then `.cursorrules`), takes the first name it finds in a folder (`.hermes.md` before `HERMES.md`, `AGENTS.override.md` before `AGENTS.md`), and takes the nearest `.hermes.md`/`HERMES.md` walking up to the git root, so creating it would hide those rules. Name the file Hermes already loads instead (the error names it), or a file in `$HERMES_HOME` for every session.
<a id="harness-render-failed"></a>
### `gstack-harness: could not render <harness> skills: ...` (HARNESS_RENDER_FAILED)
+4 -1
View File
@@ -6,11 +6,14 @@ const hermes = defineHost({
tier: 'experimental',
capabilities: { toolExecution: true, questions: 'native', planMode: false, delegation: true, browser: true, safetyHooks: 'advisory' },
// Hermes indexes skills by frontmatter name, first match wins (#2825).
// Hermes indexes skills by frontmatter name, first match wins (#2825). Its
// skills index shows 57 chars of a description and "..." (agent/skill_utils.py
// SKILL_PROMPT_DESC_LIMIT = 60), so each description leads with when to use it.
frontmatter: {
mode: 'allowlist',
keepFields: ['name', 'description'],
descriptionLimit: null,
descriptionLead: 57,
nameMatchesDirectory: true,
},
+3 -2
View File
@@ -15,8 +15,9 @@ const openclaw = defineHost({
suppressedResolvers: [...CROSS_MODEL_RESOLVERS, ...GBRAIN_RESOLVERS],
// ./setup --host openclaw (and setup run inside OpenClaw) registers through
// gstack-harness: one gstack router skill in $OPENCLAW_STATE_DIR/skills, or
// every skill with --skills all. OpenClaw's ACP route (docs/OPENCLAW.md) stays.
// gstack-harness: the task skills plus one gstack router skill for the rest in
// $OPENCLAW_STATE_DIR/skills (hybrid), or --skills router|all. OpenClaw's ACP
// route (docs/OPENCLAW.md) stays.
install: {
linkingStrategy: 'symlink-generated',
registration: 'harness',
+142 -68
View File
@@ -21,8 +21,9 @@ import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation';
import { frontmatterList, routerLine } from './skill-triggers';
export type SkillsMode = 'router' | 'all';
export type SkillsMode = 'router' | 'hybrid' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
type Env = Record<string, string | undefined>;
@@ -33,7 +34,9 @@ export const BLOCK_BEGIN = '<!-- gstack:begin harness-routing -->';
export const BLOCK_END = '<!-- gstack:end harness-routing -->';
/** Where a Dot keeps its own skills (feder-cr/dots nanobot/dots/skills.py DOT_SKILLS_DIR). */
export const DOT_SKILLS_DIR = '/home/dot/skills';
export const ROUTER_DESCRIPTION = 'gstack engineering workflows: code review, QA testing, debugging, security audit, plan and design review, shipping PRs, release docs, retros. Load first for any coding, review, test, plan or release task; it lists every gstack skill and where to read it.';
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'];
export interface Target {
name: TargetName;
@@ -62,7 +65,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: 'router', modes: ['router', 'all'],
name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'],
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')),
@@ -109,7 +112,8 @@ export const TARGETS: Record<TargetName, Target> = {
export const HARNESS_CODES = {
HARNESS_UNKNOWN_TARGET: { anchor: 'harness-unknown-target', fix: 'name one of: openclaw, hermes, dots, generic, qm, muse, grok' },
HARNESS_NO_DESTINATION: { anchor: 'harness-no-destination', fix: 'pass --skills-dir <dir> or --context <file>, or --print to see the block without writing' },
HARNESS_MODE_UNSUPPORTED: { anchor: 'harness-mode-unsupported', fix: 'drop --skills, or pass a mode the target supports (Hermes opens only listed skills, so it takes all)' },
HARNESS_MODE_UNSUPPORTED: { anchor: 'harness-mode-unsupported', fix: 'drop --skills, or pass a mode the target supports (Hermes takes all: its skills index lists only skills registered as folders, so a router would leave the rest out of the listing)' },
HARNESS_CONTEXT_REFUSED: { anchor: 'harness-context-refused', fix: 'name a rules file the harness already loads (for Hermes, the existing .hermes.md, HERMES.md or AGENTS.md), never SOUL.md' },
HARNESS_UNSAFE_DESTINATION: { anchor: 'harness-unsafe-destination', fix: 'pick a destination that is a plain folder outside the gstack checkout, not your home folder, with no tab or newline in its path' },
HARNESS_UNOWNED_ENTRY: { anchor: 'harness-unowned-entry', fix: 'move or rename the existing entry (gstack did not write it), then register again' },
HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' },
@@ -184,13 +188,6 @@ function frontmatterField(md: string, field: string): string {
return body.join(' ');
}
/** First sentence of a description, without the trailing "(gstack)" tag, at most 160 characters. */
export function oneLineUse(description: string): string {
const d = description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
const s = (d.match(/^.*?[.!?](\s|$)/)?.[0] ?? d).trim();
return s.length > 160 ? s.slice(0, 157).trimEnd() + '...' : s;
}
/** Agent Skills frontmatter in its strictest reading (dots): single-line name equal to the folder, single-line description of at most 1024 characters. */
export function flatFrontmatter(md: string, name: string): string {
const end = md.startsWith('---\n') ? md.indexOf('\n---', 4) : -1;
@@ -199,14 +196,18 @@ export function flatFrontmatter(md: string, name: string): string {
return '---\nname: ' + name + '\ndescription: ' + (desc.length > 1024 ? desc.slice(0, 1021).trimEnd() + '...' : desc) + '\n---\n' + body;
}
function skillDirs(root: string): SkillEntry[] {
/** Generated skills under root; a render whose frontmatter dropped `triggers:` takes them from triggersByName (the checkout's skills, by name). */
function skillDirs(root: string, triggersByName = new Map<string, string[]>()): Array<SkillEntry & { triggers: string[] }> {
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root).sort().flatMap(name => {
const md = path.join(root, name, 'SKILL.md');
return fs.readdirSync(root).sort().flatMap(dir => {
const md = path.join(root, dir, 'SKILL.md');
if (!fs.existsSync(md)) return [];
const text = fs.readFileSync(md, 'utf8');
if (!text.includes('AUTO-GENERATED')) return [];
return [{ name: frontmatterField(text, 'name') || name, use: oneLineUse(frontmatterField(text, 'description')), dir: path.join(root, name) }];
const name = frontmatterField(text, 'name') || dir;
const own = frontmatterList(text, 'triggers');
const triggers = own.length ? own : triggersByName.get(name) ?? triggersByName.get(name.replace(/^gstack-/, '')) ?? [];
return [{ name, use: routerLine(frontmatterField(text, 'description'), triggers), dir: path.join(root, dir), triggers }];
});
}
@@ -218,33 +219,43 @@ export function sourceSkills(target: Target, checkout: string, scratch: string):
const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'scripts/gen-skill-docs.ts', '--host', target.name, '--install-root', checkout, '--out-dir', out],
{ cwd: checkout, encoding: 'utf8', timeout: 300_000 });
if (r.status !== 0) throw new HarnessError('HARNESS_RENDER_FAILED', 'could not render ' + target.name + ' skills: ' + ((r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | ')));
const skills = skillDirs(path.join(out, '.' + target.name, 'skills'));
const skills = skillDirs(path.join(out, '.' + target.name, 'skills'), new Map(skillDirs(checkout).map(s => [s.name, s.triggers])));
if (!skills.length) throw new HarnessError('HARNESS_RENDER_FAILED', 'the ' + target.name + ' render produced no skills');
return skills;
}
export function routerSkill(skills: Array<{ name: string; use: string; path: string }>, target: Target): string {
/** The router skill; direct names the skills hybrid mode lists as skills of their own. */
export function routerSkill(skills: Array<{ name: string; use: string; path: string }>, target: Target, direct: string[] = []): string {
const claudeNote = target.render === 'claude'
? '\nThese files use Claude Code tool names (Bash, Read, Edit, Agent, AskUserQuestion); use your own equivalents.\n' : '';
const directNote = direct.length ? ' ' + direct.join(', ') + ' are skills of their own in your list; this router carries the rest.' : '';
return [
'---', 'name: gstack', 'description: ' + ROUTER_DESCRIPTION, '---',
'<!-- AUTO-GENERATED by gstack-harness register: rerun it instead of editing -->',
'# gstack skills', '',
'Pick the one skill whose line matches the task, then read that SKILL.md in full and follow it. Read nothing else up front.' + claudeNote,
'Pick the one skill whose line matches the task, then read that SKILL.md in full and follow it. Read nothing else up front.' + directNote + claudeNote,
...skills.map(s => '- **' + s.name + '**: ' + s.use + ' Read `' + s.path + '`'),
'',
].join('\n');
}
export function contextBlock(routerPath: string): string {
return [BLOCK_BEGIN, '## gstack skills',
"gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. For a coding, review, test, plan or release task, read `" + routerPath + '`, pick the one skill whose line matches, then read and follow that skill. Load nothing else up front.',
BLOCK_END].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. ";
const pick = ', pick the one skill whose line matches, then read and follow that skill. Load nothing else up front.';
const body = !routerPath
? 'For a coding, review, test, plan or release task, load the matching gstack skill from your skills list and follow it. Load nothing else up front.'
: direct.length
? 'For a task that ' + direct.join(', ') + ' fits, load that skill from your skills list; for any other coding, review, test, plan or release task, read `' + routerPath + '`' + pick
: 'For a coding, review, test, plan or release task, read `' + routerPath + '`' + pick;
return [BLOCK_BEGIN, '## gstack skills', lead + body, BLOCK_END].join('\n');
}
// --- ledger ----------------------------------------------------------------
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: Array<{ sha: string; path: string }> }
/** pad: what gstack added before a block it appended, so uninstall restores the file byte for byte: new (it created the file), 0 (the file was empty), 1 (one blank line), 2 (a newline and a blank line); '' in ledgers that predate it. */
export interface LedgerBlock { sha: string; path: string; pad: string }
export interface Ledger { meta: Record<string, string>; files: Array<{ sha: string; path: string }>; dirs: string[]; blocks: LedgerBlock[] }
export const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
export const ledgerDir = (stateRoot: string) => path.join(stateRoot, 'harness');
@@ -254,13 +265,15 @@ export const ledgerPath = (stateRoot: string, id: string) => path.join(ledgerDir
export function readLedger(file: string): Ledger | null {
if (!fs.existsSync(file)) return null;
const l: Ledger = { meta: {}, files: [], dirs: [], blocks: [] };
const pads = new Map<string, string>();
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const [kind, a, ...rest] = line.split('\t');
const b = rest.join('\t');
if (kind === 'meta') l.meta[a] = b;
else if (kind === 'file') l.files.push({ sha: a, path: b });
else if (kind === 'dir') l.dirs.push(b);
else if (kind === 'block') l.blocks.push({ sha: a, path: b });
else if (kind === 'blockpad') pads.set(b, a);
else if (kind === 'block') l.blocks.push({ sha: a, path: b, pad: pads.get(b) ?? '' });
}
return l;
}
@@ -270,7 +283,7 @@ export function writeLedger(file: string, l: Ledger): void {
...Object.entries(l.meta).map(([k, v]) => 'meta\t' + k + '\t' + v),
...l.files.map(f => 'file\t' + f.sha + '\t' + f.path),
...l.dirs.map(d => 'dir\t-\t' + d),
...l.blocks.map(b => 'block\t' + b.sha + '\t' + b.path)];
...l.blocks.flatMap(b => [...(b.pad ? ['blockpad\t' + b.pad + '\t' + b.path] : []), 'block\t' + b.sha + '\t' + b.path])];
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
fs.writeFileSync(file + '.tmp', rows.join('\n') + '\n', { mode: 0o600 });
fs.renameSync(file + '.tmp', file);
@@ -290,8 +303,10 @@ export interface RegisterOptions {
mode?: SkillsMode;
/** Destination override: the harness skills folder (--skills-dir). */
skillsDir?: string;
/** Context file for the routing block (generic). */
/** Context file for the routing block: written only into the file named here (--context). */
context?: string;
/** Refresh: context only identifies a generic registration; blocks already recorded are kept while they are still in their files, never re-added. */
inherit?: boolean;
checkout: string;
stateRoot: string;
env: Env;
@@ -336,8 +351,8 @@ function resolveDest(opts: RegisterOptions, id?: string): string | null {
return opts.context && id ? path.join(ledgerDir(opts.stateRoot), id) : null;
}
/** Desired files (absolute path -> bytes) and the router path, for a skill-folder or generic target. */
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; names: string[] } {
/** Desired files (absolute path -> bytes), the router path, the skill names the harness lists, the skills listed directly beside a router, and the skill count. */
export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMode, scratch: string): { files: Map<string, Buffer>; router: string | null; visible: string[]; direct: string[]; skills: number } {
const t = opts.target;
const skills = sourceSkills(t, opts.checkout, scratch);
const files = new Map<string, Buffer>();
@@ -346,23 +361,27 @@ 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 listed = (s: SkillEntry) => {
const to = path.join(dest, path.basename(s.dir));
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') {
for (const s of skills) {
const to = path.join(dest, path.basename(s.dir));
if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to);
}
return { files, router: null, names: skills.map(s => t.render === 'host' ? s.name : path.basename(s.dir)) };
const names = skills.map(listed);
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');
const entries = skills.map(s => {
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));
copyDir(s.dir, to);
return { name: s.name, use: s.use, path: path.join(to, 'SKILL.md') };
});
const router = path.join(routerDir, 'SKILL.md');
files.set(router, Buffer.from(routerSkill(entries, t)));
return { files, router, names: ['gstack', ...entries.map(e => e.name)] };
files.set(router, Buffer.from(routerSkill(entries, t, direct)));
return { files, router, visible: ['gstack', ...direct], direct, skills: skills.length };
}
/** An edited file stays beside itself under a name no harness loads, so no uninstall step that removes gstack's folders can take it. */
@@ -389,25 +408,67 @@ function onDiskSha(file: string): string | null {
/** The ledger's hash of a context block: its lines, each ending in a newline, as the bash reader's awk prints them. */
export const blockSha = (block: string) => sha256(block + '\n');
/** The gstack block in a file: absent (no marker lines, or no file), owned (one marker pair hashing to sha) or other. */
export function blockState(file: string, sha: string): 'absent' | 'owned' | 'other' {
if (!fs.existsSync(file)) return 'absent';
const lines = fs.readFileSync(file, 'utf8').split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
if (!bs.length && !es.length) return 'absent';
return bs.length === 1 && es.length === 1 && es[0] > bs[0] && sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n') === sha ? 'owned' : 'other';
}
/**
* The text of file with the block in it: replaces the one gstack block there (whole-line markers) when it is the block
* gstack wrote (ownedSha) or already current, else appends. A lone or repeated marker, or a block someone
* edited, is refused so nothing outside gstack's own lines is ever rewritten.
* The text of file with the block in it, and the pad gstack added before an appended block: replaces the one gstack
* block there (whole-line markers) when it is the block gstack wrote (ownedSha) or already current (pad ''), else
* appends. A lone or repeated marker, or a block someone edited, is refused so nothing outside gstack's own lines is
* ever rewritten.
*/
export function withBlock(file: string, block: string, ownedSha?: string): string {
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
export function withBlock(file: string, block: string, ownedSha?: string): { text: string; pad: string } {
const exists = fs.existsSync(file);
const text = exists ? fs.readFileSync(file, 'utf8') : '';
const lines = text.split('\n');
const at = (m: string) => lines.flatMap((l, i) => l === m ? [i] : []);
const [bs, es] = [at(BLOCK_BEGIN), at(BLOCK_END)];
let next: string;
if (!bs.length && !es.length) next = text + (text && !text.endsWith('\n') ? '\n' : '') + (text ? '\n' : '') + block + '\n';
else if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
if (!bs.length && !es.length) {
const pad = !exists ? 'new' : !text ? '0' : text.endsWith('\n') ? '1' : '2';
return { text: text + (pad === '2' ? '\n\n' : pad === '1' ? '\n' : '') + block + '\n', pad };
}
if (bs.length === 1 && es.length === 1 && es[0] > bs[0]) {
const cur = sha256(lines.slice(bs[0], es[0] + 1).join('\n') + '\n');
if (cur !== blockSha(block) && cur !== ownedSha) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'the gstack block in ' + file + ' was edited or gstack did not write it; delete the lines from ' + BLOCK_BEGIN + ' to ' + BLOCK_END);
lines.splice(bs[0], es[0] - bs[0] + 1, ...block.split('\n'));
next = lines.join('\n');
} else throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
return next;
return { text: lines.join('\n'), pad: '' };
}
throw new HarnessError('HARNESS_UNOWNED_ENTRY', file + ' has a gstack harness-routing marker line without exactly one partner; fix or delete the marker lines');
}
/** Whether a context file belongs to the harness's own home (profile: every session) or to a project (sessions started there). */
export function contextScope(t: Target, file: string, env: Env, home: string): 'profile' | 'project' {
const harnessHome = t.name === 'hermes' ? env.HERMES_HOME || path.join(home, '.hermes') : t.name === 'openclaw' ? env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw') : null;
return harnessHome && path.resolve(file).startsWith(path.resolve(harnessHome) + path.sep) ? 'profile' : 'project';
}
/** Hermes loads one project context type, first match wins, walking to the git root (agent/prompt_builder.py build_context_files_prompt); within a group the first name in a folder wins, and the nearest .hermes.md/HERMES.md wins. */
const HERMES_CONTEXT_ORDER = [['.hermes.md', 'HERMES.md'], ['AGENTS.override.md', 'AGENTS.md', 'agents.md'], ['CLAUDE.md'], ['.cursorrules']];
/** Refuses SOUL.md (the harness's identity file), and for Hermes a new file that would shadow the rules it loads now. */
function assertContextFile(t: Target, file: string): void {
const refuse = (why: string) => { throw new HarnessError('HARNESS_CONTEXT_REFUSED', 'refusing to write the gstack block into ' + file + ': ' + why); };
if (path.basename(file).toLowerCase() === 'soul.md') refuse('SOUL.md is the harness\'s identity file');
if (t.name !== 'hermes' || fs.existsSync(file)) return;
const rank = HERMES_CONTEXT_ORDER.findIndex(g => g.includes(path.basename(file)));
if (rank < 0) return;
const group = HERMES_CONTEXT_ORDER[rank];
const later = HERMES_CONTEXT_ORDER.slice(rank + 1).flat();
const dirs = [path.dirname(file)];
for (let d = dirs[0]; !fs.existsSync(path.join(d, '.git')) && path.dirname(d) !== d; d = path.dirname(d)) dirs.push(path.dirname(d));
const chain = fs.existsSync(path.join(dirs.at(-1)!, '.git')) ? dirs : dirs.slice(0, 1);
for (const dir of chain) {
const own = dir === dirs[0] ? group.slice(group.indexOf(path.basename(file)) + 1) : rank === 0 ? group : [];
const shadowed = [...own, ...later].find(n => fs.existsSync(path.join(dir, n)));
if (shadowed) refuse('Hermes loads ' + path.basename(file) + ' instead of ' + path.join(dir, shadowed) + ', so creating it would hide those rules; name that file instead');
}
}
/** Runs fn from one of the checkout's sourced bash libraries, passing args through the environment. */
@@ -417,11 +478,6 @@ function sourcedBash(checkout: string, lib: string, fn: string, args: string[],
{ env: { ...process.env, ...env, ...argEnv, GSTACK_HARNESS_LIB: path.join(checkout, 'bin', lib), GSTACK_STATE_ROOT: stateRoot }, encoding: 'utf8', timeout: 60_000 });
}
/** Cuts a block this ledger recorded through the bash reader, so register and uninstall share one cutter. */
function cutBlock(opts: RegisterOptions, file: string, sha: string): void {
process.stdout.write(sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', '_gstack_harness_cut_block', [file, sha], opts.stateRoot, opts.env).stdout || '');
}
function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boolean {
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
}
@@ -437,14 +493,13 @@ export function register(opts: RegisterOptions): RegisterResult {
const id = registrationId(t.name, provisional);
const dest = resolveDest(opts, id)!;
assertSafeDest(dest, opts);
if (opts.context) assertSafeDest(path.resolve(opts.context), opts);
const explicit = opts.context && !opts.inherit ? path.resolve(opts.context) : '';
if (explicit) { assertSafeDest(explicit, opts); assertContextFile(t, explicit); }
const lfile = ledgerPath(opts.stateRoot, id);
const prev = readLedger(lfile);
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-'));
try {
const { files, router, names } = planPayload(opts, dest, mode, scratch);
const skills = mode === 'router' ? names.length - 1 : names.length;
const visible = mode === 'router' ? ['gstack'] : names;
const { files, router, visible, direct, skills } = planPayload(opts, dest, mode, scratch);
const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha]));
const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]);
const owned = new Set([...prevFiles.keys()].map(topOf));
@@ -463,13 +518,28 @@ export function register(opts: RegisterOptions): RegisterResult {
}
for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p);
const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) }));
const ctx = opts.context ? path.resolve(opts.context) : '';
const block = opts.context ? contextBlock(router ?? '') : null;
if (block && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
const blocks = block ? [{ sha: blockSha(block), path: ctx }] : [];
const ctxText = block ? withBlock(ctx, block, prev?.blocks.find(b => b.path === ctx)?.sha) : null;
const context = opts.context ? path.resolve(opts.context) : prev?.meta.context ?? '';
const block = contextBlock(router, direct);
if ((explicit || prev?.blocks.length) && block.length > CONTEXT_BLOCK_MAX) throw new HarnessError('HARNESS_BUDGET_EXCEEDED', 'context block is ' + block.length + ' bytes');
const blocks: LedgerBlock[] = [];
const blockWrites = new Map<string, string>();
for (const b of prev?.blocks ?? []) {
if (b.path === explicit) continue;
const state = blockState(b.path, b.sha);
if (state === 'absent') continue;
if (state === 'other') { blocks.push(b); continue; }
blockWrites.set(b.path, withBlock(b.path, block, b.sha).text);
blocks.push({ sha: blockSha(block), path: b.path, pad: b.pad });
}
if (explicit) {
const before = prev?.blocks.find(b => b.path === explicit);
const w = withBlock(explicit, block, before?.sha);
blockWrites.set(explicit, w.text);
blocks.push({ sha: blockSha(block), path: explicit, pad: w.pad || before?.pad || '' });
}
const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length
&& (prev.meta.context ?? '') === ctx && prev.blocks.every(b => blocks.some(n => n.path === b.path && n.sha === b.sha))
&& (prev.meta.context ?? '') === context && prev.blocks.length === blocks.length
&& blocks.every(b => prev.blocks.some(p => p.path === b.path && p.sha === b.sha && p.pad === b.pad))
&& want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const kept: string[] = [];
@@ -478,16 +548,19 @@ export function register(opts: RegisterOptions): RegisterResult {
const dirs = new Set(prev?.dirs ?? []);
for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d);
const record = (recorded: typeof want) => writeLedger(lfile, {
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(ctx ? { context: ctx } : {}) },
meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}) },
files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks,
});
record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]);
for (const b of prev?.blocks ?? []) if (b.path !== ctx) cutBlock(opts, b.path, b.sha);
for (const [p, sha] of prevFiles) {
if (files.has(p)) continue;
const disk = onDiskSha(p);
if (disk === sha) fs.rmSync(p, { force: true }); else if (disk) kept.push(keepEdit(p, stamp));
}
for (const dir of [...dirs].sort((a, b) => b.length - a.length)) {
if ([...files.keys()].some(p => p.startsWith(dir + path.sep))) continue;
try { fs.rmdirSync(dir); dirs.delete(dir); } catch {}
}
for (const [p, b] of files) {
const disk = onDiskSha(p);
if (disk === sha256(b)) continue;
@@ -499,9 +572,10 @@ export function register(opts: RegisterOptions): RegisterResult {
}
record(want);
}
if (ctxText !== null && ctxText !== (fs.existsSync(ctx) ? fs.readFileSync(ctx, 'utf8') : null)) {
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, ctxText);
for (const [file, text] of blockWrites) {
if (text === (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, text);
}
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept };
@@ -536,7 +610,7 @@ export function refresh(base: Omit<RegisterOptions, 'target'>, skip: Set<string>
.map(({ ledger: l }) => register({
...base, target: getTarget(l.meta.target), mode: l.meta.mode as SkillsMode,
skillsDir: l.meta.context && l.meta.dest.startsWith(ledgerDir(base.stateRoot)) ? undefined : l.meta.dest,
context: l.meta.context || undefined,
context: l.meta.context || undefined, inherit: true,
}));
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Trigger-first skill wording for harness listings (GSTA-135 skill use).
*
* Frozen rule, built only from each skill's own frontmatter, and the trigger
* part always comes first:
* - Router lines (lib/harness-register.ts, 160 chars): the description's own
* trigger sentence (its first sentence that starts "Use when", "Use to",
* "Use for", ...), followed by the description's first sentence when both
* fit. A description with no trigger sentence (the catalog-trimmed Claude
* render) uses the triggers list below instead.
* - Hermes descriptions (scripts/gen-skill-docs.ts, hosts/hermes.ts): a lead
* of at most 57 chars, the part Hermes's skills index shows: "Use for: "
* and the skill's `triggers:` list in frontmatter order, as many whole
* entries as fit; without triggers, the trigger sentence (or first
* sentence) cut at a word. The description's own trigger sentence, which
* the list repeats, is dropped, so a render does not grow.
*/
const TRIGGER_SENTENCE = /^Use (when|whenever|to|for|after|before|on|if)\b/;
export const normalizeDescription = (description: string) => description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
export const sentences = (description: string): string[] => normalizeDescription(description).split(/(?<=[.!?])\s+/).filter(Boolean);
/** Cuts at the last word boundary that leaves room for "...". */
export function cutWords(text: string, max: number): string {
if (text.length <= max) return text;
const cut = text.slice(0, max - 3);
const space = cut.lastIndexOf(' ');
return (space > max / 3 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '') + '...';
}
/** The description's own trigger sentence, or ''. */
export const triggerSentence = (description: string) => sentences(description).find(s => TRIGGER_SENTENCE.test(s)) ?? '';
/** "Use for: a, b, c." from a skill's triggers list, as many whole entries as fit in max chars ('' without triggers). */
export function triggerList(triggers: string[], max: number): string {
const items = triggers.map(t => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
if (!items.length) return '';
let lead = 'Use for: ' + items[0] + '.';
for (const t of items.slice(1)) {
const next = lead.slice(0, -1) + ', ' + t + '.';
if (next.length > max) break;
lead = next;
}
return cutWords(lead, max);
}
/** A trigger-first lead of at most max chars: the triggers list, else the trigger sentence or first sentence cut at a word. */
export function triggerLead(description: string, triggers: string[], max: number): string {
return triggerList(triggers, max) || cutWords(triggerSentence(description) || sentences(description)[0] || '', max);
}
/** A description led by triggerLead on its own first line; the description's trigger sentence is dropped when the lead comes from the triggers list, which that sentence repeats. */
export function withTriggerLead(description: string, triggers: string[], max: number): string {
const lead = triggerLead(description, triggers, max);
const sentence = triggerSentence(description);
if (!sentence || !triggerList(triggers, max)) return lead + '\n' + description;
const tag = /\(gstack\)\s*$/.test(description) ? ' (gstack)' : '';
return lead + '\n' + sentences(description).filter(s => s !== sentence).join(' ') + tag;
}
/** A router line of at most max chars: the trigger part first, then the description's first sentence when both fit. */
export function routerLine(description: string, triggers: string[], max = 160): string {
const first = sentences(description)[0] ?? '';
const trigger = triggerSentence(description) || triggerList(triggers, max);
if (!trigger) return cutWords(first, max);
const both = trigger + ' ' + first;
return first && first !== trigger && both.length <= max ? both : cutWords(trigger, max);
}
/** The values of a YAML block list field (`field:` then ` - value` lines) in a SKILL.md's frontmatter. */
export function frontmatterList(md: string, field: string): string[] {
const fm = md.startsWith('---\n') ? md.slice(4, md.indexOf('\n---', 4)) : '';
const lines = fm.split('\n');
const i = lines.findIndex(l => l.trimEnd() === field + ':');
if (i < 0) return [];
const out: string[] = [];
for (const l of lines.slice(i + 1)) {
const m = /^\s*-\s+(.*)$/.exec(l);
if (!m) break;
out.push(m[1].trim().replace(/^["']|["']$/g, ''));
}
return out;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.316",
"version": "1.91.317",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+3 -1
View File
@@ -29,6 +29,7 @@ import type { ModuleFilter } from './resolvers/types';
import { insertRuntimePreludes } from './resolvers/runtime-root';
import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
import { extractVoiceTriggers } from './resolvers/voice-triggers';
import { frontmatterList, withTriggerLead } from '../lib/skill-triggers';
import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
import type { HostConfig } from './host-config';
@@ -510,7 +511,8 @@ function transformFrontmatter(content: string, host: Host): string {
if (fmEnd === -1) return content;
const frontmatter = content.slice(fmStart + 4, fmEnd);
const body = content.slice(fmEnd + 4);
const { name, description } = extractNameAndDescription(content);
const { name, description: fullDescription } = extractNameAndDescription(content);
const description = fm.descriptionLead ? withTriggerLead(fullDescription, frontmatterList(content, 'triggers'), fm.descriptionLead) : fullDescription;
// Description limit enforcement
if (fm.descriptionLimit) {
+6
View File
@@ -81,6 +81,12 @@ export interface HostConfig {
descriptionLimit?: number | null;
/** What to do when description exceeds limit. Default: 'error'. */
descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
/**
* Put a trigger-first lead of at most this many chars on the first line of
* the description (lib/skill-triggers.ts withTriggerLead), for hosts whose
* skills index shows only the start of a description.
*/
descriptionLead?: number;
/** Additional frontmatter fields to inject (host-wide). */
extraFields?: Record<string, unknown>;
/**
+2 -2
View File
@@ -31,8 +31,8 @@
"factory/gstack-review": 125387,
"gbrain/gstack-autoplan": 108892,
"gbrain/gstack-plan-eng-review": 126289,
"hermes/gstack-autoplan": 108982,
"hermes/gstack-plan-eng-review": 126374,
"hermes/gstack-autoplan": 108891,
"hermes/gstack-plan-eng-review": 126321,
"kiro/gstack-autoplan": 108259,
"kiro/gstack-plan-design-review": 108752,
"kiro/gstack-plan-devex-review": 115080,
+264 -10
View File
@@ -10,8 +10,8 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
contextBlock, detect, listLedgers, readLedger, register, unregister, type RegisterOptions,
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,
} 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';
@@ -73,14 +73,14 @@ describe('gstack-harness register and unregister (skill folders)', () => {
const skills = path.join(d, 'oc', 'skills');
fs.mkdirSync(path.join(skills, 'mine'), { recursive: true });
fs.writeFileSync(path.join(skills, 'mine', 'SKILL.md'), '---\nname: mine\ndescription: my own\n---\nmine\n');
const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } }));
const r = register(opts(d, 'openclaw', { mode: 'router', env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } }));
expect(r.mode).toBe('router');
expect(r.skills).toBeGreaterThan(20);
expect(fs.readdirSync(skills).sort()).toEqual(['gstack', 'mine']);
const router = fs.readFileSync(path.join(skills, 'gstack', 'SKILL.md'), 'utf8');
expect(router).toContain('description: ' + ROUTER_DESCRIPTION);
for (const [, p] of router.matchAll(/Read \x60([^\x60]+)\x60/g)) expect(fs.existsSync(p), p).toBe(true);
expect(register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } })).status).toBe('unchanged');
expect(register(opts(d, 'openclaw', { mode: 'router', env: { OPENCLAW_STATE_DIR: path.join(d, 'oc') } })).status).toBe('unchanged');
unregister(path.join(d, 'state'), ROOT, r.id);
expect(files(skills)).toEqual(['mine/SKILL.md']);
expect(listLedgers(path.join(d, 'state'))).toEqual([]);
@@ -150,7 +150,7 @@ describe('gstack-harness register and unregister (skill folders)', () => {
expect(fs.readFileSync(ctx, 'utf8').split(BLOCK_BEGIN).length).toBe(2);
fs.appendFileSync(r.router!, '\nmy note\n');
unregister(path.join(d, 'state'), ROOT, 'generic');
expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n\n');
expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n');
expect(fs.existsSync(r.router!)).toBe(false);
const kept = files(path.dirname(r.router!));
expect(kept).toHaveLength(1);
@@ -170,7 +170,7 @@ describe('gstack-harness register and unregister (skill folders)', () => {
fs.writeFileSync(ctx, prose);
register(opts(d, 'generic', { context: ctx }));
unregister(path.join(d, 'state'), ROOT, 'generic');
expect(fs.readFileSync(ctx, 'utf8')).toBe(prose + '\n');
expect(fs.readFileSync(ctx, 'utf8')).toBe(prose);
for (const edit of [(s: string) => s.replace(BLOCK_END + '\n', ''), (s: string) => s.replace('## gstack skills', '## gstack skills\nmy own line')]) {
fs.writeFileSync(ctx, '# mine\n');
register(opts(d, 'generic', { context: ctx }));
@@ -183,7 +183,7 @@ describe('gstack-harness register and unregister (skill folders)', () => {
}
}, 60_000);
test('generic: adding or moving --context is recorded, and the old block is cut', () => {
test('generic: adding --context is recorded, a second file keeps the first block (keyed by file), and uninstall restores both', () => {
const d = tmp();
const [a, b] = [path.join(d, 'a.md'), path.join(d, 'b.md')];
fs.writeFileSync(a, '# a\n');
@@ -193,10 +193,11 @@ describe('gstack-harness register and unregister (skill folders)', () => {
expect(register(opts(d, 'dots', { skillsDir: skills, context: a })).status).toBe('updated');
expect(fs.readFileSync(a, 'utf8')).toContain(BLOCK_BEGIN);
const r = register(opts(d, 'dots', { skillsDir: skills, context: b }));
expect(fs.readFileSync(a, 'utf8')).toBe('# a\n\n');
expect(readLedger(r.ledger)!.blocks.map(x => x.path)).toEqual([b]);
expect(fs.readFileSync(a, 'utf8')).toContain(BLOCK_BEGIN);
expect(readLedger(r.ledger)!.blocks.map(x => x.path)).toEqual([a, b]);
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.readFileSync(b, 'utf8')).toBe('# b\n\n');
expect(fs.readFileSync(a, 'utf8')).toBe('# a\n');
expect(fs.readFileSync(b, 'utf8')).toBe('# b\n');
}, 60_000);
test('a folder swapped for a symlink below the destination is never followed', () => {
@@ -216,6 +217,175 @@ describe('gstack-harness register and unregister (skill folders)', () => {
}, 60_000);
});
describe('GSTA-135: registered skills get used', () => {
const descriptionOf = (md: string) => { const m = /^description: \|\n((?: .*\n)+)/m.exec(md); return m ? m[1].split('\n').map(l => l.trim()).filter(Boolean) : []; };
test('router lines lead with each skill\'s trigger, within 160 chars; the router description leads with task verbs', () => {
expect(ROUTER_DESCRIPTION.length).toBeLessThanOrEqual(ROUTER_DESCRIPTION_MAX);
for (const verb of ['review', 'debug', 'test', 'plan', 'ship']) expect(ROUTER_DESCRIPTION.slice(0, 200), verb).toContain(verb);
const d = tmp();
for (const [target, mode] of [['openclaw', 'router'], ['dots', 'router']] as const) {
const r = register(opts(d, target, { mode, skillsDir: path.join(d, target) }));
const lines = fs.readFileSync(r.router!, 'utf8').split('\n').filter(l => l.startsWith('- **'));
expect(lines.length).toBe(r.skills);
for (const l of lines) {
const use = l.replace(/^- \*\*[^*]+\*\*: /, '').replace(/ Read \x60[^\x60]+\x60$/, '');
expect(use, l).toMatch(/^Use /);
expect(use.length, l).toBeLessThanOrEqual(160);
}
}
}, 120_000);
test('Hermes: every rendered description opens with a trigger-first lead of at most 57 chars; the OpenClaw render keeps its own', () => {
const d = tmp();
const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } }));
const mds = files(r.dest).filter(f => f.endsWith('/SKILL.md'));
expect(mds.length).toBe(r.skills);
for (const f of mds) {
const lead = descriptionOf(fs.readFileSync(path.join(r.dest, f), 'utf8'))[0] ?? '';
expect(lead, f).toMatch(/^Use /);
expect(lead.length, f).toBeLessThanOrEqual(57);
}
const oc = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'oc') }));
const review = fs.readFileSync(path.join(oc.dest, 'gstack-review', 'SKILL.md'), 'utf8');
expect(descriptionOf(review)[0]).not.toMatch(/^Use /);
}, 120_000);
test('OpenClaw hybrid is the default: the task skills listed directly, the router for the rest; mode switches refresh and uninstall cleanly', () => {
const d = tmp();
const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') };
const skills = path.join(d, 'oc', 'skills');
fs.mkdirSync(path.join(skills, 'mine'), { recursive: true });
fs.writeFileSync(path.join(skills, 'mine', 'SKILL.md'), '---\nname: mine\ndescription: my own\n---\nmine\n');
const r = register(opts(d, 'openclaw', { env }));
expect(r.mode).toBe('hybrid');
expect(fs.readdirSync(skills).sort()).toEqual(['gstack', ...HYBRID_SKILLS.map(s => 'gstack-' + s), 'mine'].sort());
const router = fs.readFileSync(r.router!, 'utf8');
for (const s of HYBRID_SKILLS) expect(router, s).not.toContain('- **' + s + '**:');
expect(router.split('\n').filter(l => l.startsWith('- **')).length).toBe(r.skills - HYBRID_SKILLS.length);
expect(readLedger(r.ledger)!.meta.names.split(',').sort()).toEqual(['gstack', ...HYBRID_SKILLS].sort());
const base = { checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION };
expect(refresh(base).map(x => x.status)).toEqual(['unchanged']);
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');
expect(top.length).toBe(mode === 'all' ? r.skills : mode === 'router' ? 1 : 1 + HYBRID_SKILLS.length);
}
unregister(path.join(d, 'state'), ROOT, r.id);
expect(files(skills)).toEqual(['mine/SKILL.md']);
}, 180_000);
test('context block texts are mode-aware and within budget', () => {
const long = '/home/someone-with-a-long-name/.openclaw-state-directory/skills/gstack/SKILL.md';
const variants = [contextBlock(long), contextBlock(long, HYBRID_SKILLS.map(s => 'gstack-' + s)), contextBlock(null)];
for (const b of variants) {
expect(b.length).toBeLessThanOrEqual(CONTEXT_BLOCK_MAX);
expect(b.startsWith(BLOCK_BEGIN) && b.endsWith(BLOCK_END)).toBe(true);
}
expect(variants[1]).toContain('gstack-review');
expect(variants[2]).not.toContain('\x60');
});
test('--context for openclaw and hermes: opt-in, keyed by file, kept across mode switches, never re-added once deleted, restored byte for byte', () => {
const d = tmp();
const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') };
const [a, b, c, e] = ['a/AGENTS.md', 'b/AGENTS.md', 'c/TOOLS.md', 'e/AGENTS.md'].map(p => path.join(d, 'proj', p));
for (const p of [a, b, e]) fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(a, '# a rules\n');
fs.writeFileSync(b, '# b rules, no newline');
fs.writeFileSync(e, '');
const plain = register(opts(d, 'openclaw', { env }));
expect(readLedger(plain.ledger)!.blocks).toEqual([]);
for (const p of [a, b, c, e]) register(opts(d, 'openclaw', { env, context: p }));
expect(readLedger(plain.ledger)!.blocks.map(x => [x.path, x.pad])).toEqual([[a, '1'], [b, '2'], [c, 'new'], [e, '0']]);
for (const p of [a, b, c, e]) expect(fs.readFileSync(p, 'utf8'), p).toContain(BLOCK_BEGIN);
register(opts(d, 'openclaw', { env, mode: 'all' }));
expect(fs.readFileSync(a, 'utf8')).toContain(contextBlock(null));
fs.writeFileSync(b, '# b rules, no newline');
const base = { checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION };
refresh(base);
expect(fs.readFileSync(b, 'utf8')).toBe('# b rules, no newline');
expect(readLedger(plain.ledger)!.blocks.map(x => x.path)).toEqual([a, c, e]);
unregister(path.join(d, 'state'), ROOT, plain.id);
expect(fs.readFileSync(a, 'utf8')).toBe('# a rules\n');
expect(fs.existsSync(c)).toBe(false);
expect(fs.readFileSync(e, 'utf8')).toBe('');
const h = tmp();
const hb = path.join(h, 'proj', 'notes.md');
fs.mkdirSync(path.dirname(hb), { recursive: true });
fs.writeFileSync(hb, 'no newline at end');
const hr = register(opts(h, 'hermes', { env: { HERMES_HOME: path.join(h, 'hermes') }, context: hb }));
expect(fs.readFileSync(hb, 'utf8')).toContain(contextBlock(null));
unregister(path.join(h, 'state'), ROOT, hr.id);
expect(fs.readFileSync(hb, 'utf8')).toBe('no newline at end');
}, 180_000);
test('--context refuses SOUL.md and a new Hermes file that would shadow the rules Hermes loads now; scope is profile inside the harness home', () => {
const d = tmp();
const env = { HERMES_HOME: path.join(d, 'hermes') };
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
const proj = path.join(d, 'proj');
fs.mkdirSync(path.join(proj, '.git'), { recursive: true });
fs.mkdirSync(path.join(proj, 'sub'));
fs.writeFileSync(path.join(proj, 'AGENTS.md'), '# team rules\n');
for (const t of ['hermes', 'openclaw'] as const) expect(code(() => register(opts(d, t, { env, skillsDir: path.join(d, t + '-skills'), context: path.join(env.HERMES_HOME, 'SOUL.md') })))).toBe('HARNESS_CONTEXT_REFUSED');
expect(code(() => register(opts(d, 'hermes', { env, context: path.join(proj, 'sub', '.hermes.md') })))).toBe('HARNESS_CONTEXT_REFUSED');
expect(fs.existsSync(path.join(proj, 'sub', '.hermes.md'))).toBe(false);
fs.writeFileSync(path.join(proj, 'CLAUDE.md'), '# claude\n');
fs.rmSync(path.join(proj, 'AGENTS.md'));
expect(code(() => register(opts(d, 'hermes', { env, context: path.join(proj, 'AGENTS.md') })))).toBe('HARNESS_CONTEXT_REFUSED');
fs.writeFileSync(path.join(proj, '.hermes.md'), '# hermes rules\n');
expect(code(() => register(opts(d, 'hermes', { env, context: path.join(proj, '.hermes.md') })))).toBe('ok');
expect(fs.existsSync(path.join(env.HERMES_HOME, 'SOUL.md'))).toBe(false);
expect(contextScope(TARGETS.hermes, path.join(env.HERMES_HOME, 'AGENTS.md'), env, d)).toBe('profile');
expect(contextScope(TARGETS.hermes, path.join(proj, '.hermes.md'), env, d)).toBe('project');
expect(contextScope(TARGETS.openclaw, path.join(d, 'home', '.openclaw', 'workspace', 'AGENTS.md'), {}, path.join(d, 'home'))).toBe('profile');
}, 120_000);
test('--context refuses a new Hermes file that outranks one of its own group (first name per folder, nearest .hermes.md/HERMES.md) and soul.md in any case', () => {
const d = tmp();
const env = { HERMES_HOME: path.join(d, 'hermes') };
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
const proj = path.join(d, 'proj');
fs.mkdirSync(path.join(proj, '.git'), { recursive: true });
fs.mkdirSync(path.join(proj, 'sub'));
fs.writeFileSync(path.join(proj, 'AGENTS.md'), '# team rules\n');
fs.writeFileSync(path.join(proj, 'HERMES.md'), '# hermes rules\n');
for (const f of ['AGENTS.override.md', '.hermes.md', path.join('sub', 'HERMES.md'), path.join('sub', '.hermes.md')]) {
expect(code(() => register(opts(d, 'hermes', { env, context: path.join(proj, f) }))), f).toBe('HARNESS_CONTEXT_REFUSED');
expect(fs.existsSync(path.join(proj, f))).toBe(false);
}
expect(code(() => register(opts(d, 'hermes', { env, context: path.join(env.HERMES_HOME, 'soul.md') })))).toBe('HARNESS_CONTEXT_REFUSED');
}, 120_000);
test('the CLI prints the resolved context file and its scope before it writes', () => {
const d = tmp();
const ctx = path.join(d, 'proj', 'AGENTS.md');
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, '# mine\n');
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'register', 'openclaw', '--context', ctx, '--no-registry'],
{ env: { ...process.env, GSTACK_STATE_ROOT: path.join(d, 'state'), HOME: path.join(d, 'home'), OPENCLAW_STATE_DIR: path.join(d, 'oc') }, encoding: 'utf8', timeout: 120_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.split('\n')[0]).toBe('gstack-harness: context block: ' + ctx + ' (project scope)');
}, 120_000);
const hermesSrc = process.env.GSTACK_HERMES_SRC;
test.skipIf(!hermesSrc)('every context block variant passes Hermes\'s pinned threat scanner (GSTACK_HERMES_SRC)', () => {
const scan = (text: string) => spawnSync(process.env.GSTACK_HERMES_PYTHON || 'python3', ['-c', 'import sys, json; sys.path.insert(0, sys.argv[1]); from tools.threat_patterns import scan_for_threats; print(json.dumps(scan_for_threats(sys.stdin.read(), scope="context")))', hermesSrc!],
{ input: text, encoding: 'utf8', timeout: 60_000 });
const control = scan('Ignore all previous instructions.');
expect(control.status, control.stderr).toBe(0);
expect(JSON.parse(control.stdout)).toContain('prompt_injection');
const router = path.join(os.homedir(), '.openclaw', 'skills', 'gstack', 'SKILL.md');
for (const b of [contextBlock(router), contextBlock(router, HYBRID_SKILLS.map(s => 'gstack-' + s)), contextBlock(null)]) {
const r = scan(b);
expect(r.status, r.stderr).toBe(0);
expect(JSON.parse(r.stdout), b).toEqual([]);
}
}, 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();
@@ -413,3 +583,87 @@ describe.skipIf(process.platform === 'win32')('setup and uninstall with harnesse
expect(tree(path.join(f.home, '.claude'))).toEqual(before);
}, 180_000);
});
describe('GSTA-135: upgrades from ledgers and blocks gstack did not write this way', () => {
/**
* Value: protects=uninstall of a ledger written before blockpad rows still cuts the block and removes a file left with only whitespace;
* fails_when=the pad-less branch of _gstack_harness_cut_block stops cutting, eats user lines, or leaves a whitespace-only file behind;
* why_new=every other cut test registers with this code, so its ledger always carries a blockpad row; seam=none;
* no_claim=ledgers older than the block row itself (hand-edited ledger stands in for a v1.91.290 install)
*/
test('a ledger without blockpad rows (written before this change) still uninstalls its blocks', () => {
const d = tmp();
const [kept, created] = [path.join(d, 'proj', 'AGENTS.md'), path.join(d, 'proj', 'NEW.md')];
fs.mkdirSync(path.dirname(kept), { recursive: true });
fs.writeFileSync(kept, '# a rules\n');
const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') };
register(opts(d, 'openclaw', { env, mode: 'router', context: kept }));
const r = register(opts(d, 'openclaw', { env, mode: 'router', context: created }));
const rows = fs.readFileSync(r.ledger, 'utf8').split('\n');
expect(rows.filter(l => l.startsWith('blockpad\t'))).toHaveLength(2);
fs.writeFileSync(r.ledger, rows.filter(l => !l.startsWith('blockpad\t')).join('\n'));
expect(readLedger(r.ledger)!.blocks.map(b => [b.path, b.pad])).toEqual([[kept, ''], [created, '']]);
unregister(path.join(d, 'state'), ROOT, r.id);
expect(fs.readFileSync(kept, 'utf8')).toBe('# a rules\n\n');
expect(fs.existsSync(created)).toBe(false);
}, 120_000);
/**
* Value: protects=refresh (setup and upgrade) keeps a context block the user edited, untouched and still recorded, instead of failing;
* fails_when=refresh rewrites an edited block or throws HARNESS_UNOWNED_ENTRY from withBlock, aborting every registration's refresh;
* why_new=refresh tests cover owned and deleted blocks only; the blockState 'other' branch has no test; seam=none;
* no_claim=edits outside the marker lines (covered by the generic marker tests)
*/
test('refresh leaves a context block the user edited as it is and keeps it in the ledger', () => {
const d = tmp();
const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') };
const ctx = path.join(d, 'proj', 'AGENTS.md');
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, '# mine\n');
const r = register(opts(d, 'openclaw', { env, context: ctx }));
const edited = fs.readFileSync(ctx, 'utf8').replace('## gstack skills', '## gstack skills\nmy own line');
fs.writeFileSync(ctx, edited);
const before = readLedger(r.ledger)!.blocks;
const base = { checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION };
expect(refresh(base).map(x => x.status)).toEqual(['unchanged']);
expect(fs.readFileSync(ctx, 'utf8')).toBe(edited);
expect(readLedger(r.ledger)!.blocks).toEqual(before);
}, 120_000);
/**
* Value: protects=register --json keeps stdout one JSON object (context notice on stderr) and its receipt names hybrid mode;
* fails_when=the context notice goes to stdout under --json, or receiptLines has no hybrid wording (prints undefined);
* why_new=the CLI context test runs without --json and reads only the first stdout line; seam=none;
* no_claim=how setup renders the receipt (setup calls register --json without --context)
*/
test('the CLI with --json and --context: stdout is one JSON object with the hybrid receipt; the context notice goes to stderr', () => {
const d = tmp();
const ctx = path.join(d, 'proj', 'AGENTS.md');
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(ctx, '# mine\n');
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'register', 'openclaw', '--context', ctx, '--json', '--no-registry'],
{ env: { ...process.env, GSTACK_STATE_ROOT: path.join(d, 'state'), HOME: path.join(d, 'home'), OPENCLAW_STATE_DIR: path.join(d, 'oc') }, encoding: 'utf8', timeout: 120_000 });
expect(r.status, r.stderr).toBe(0);
const out = JSON.parse(r.stdout);
expect(out.mode).toBe('hybrid');
expect(out.receipt).toContain('the task skills listed directly and the rest through one router skill');
expect(out.receipt).not.toContain('undefined');
expect(r.stderr).toContain('gstack-harness: context block: ' + ctx + ' (project scope)');
}, 120_000);
/**
* Value: protects=outside a git repository the Hermes shadow check looks only at the new file's own folder, so a parent folder's AGENTS.md does not refuse it;
* fails_when=the walk-up chain is used without a .git root, refusing --context in any folder below a home or workspace that holds rules files;
* why_new=the shadow tests all run inside a fixture with .git; the no-repository branch has no test; seam=none;
* no_claim=which file Hermes really loads outside a repository (agent/prompt_builder.py not run here)
*/
test('Hermes --context outside a git repository checks only the file\'s own folder for files it would shadow', () => {
const d = tmp();
const env = { HERMES_HOME: path.join(d, 'hermes') };
const ctx = path.join(d, 'loose', 'sub', '.hermes.md');
fs.mkdirSync(path.dirname(ctx), { recursive: true });
fs.writeFileSync(path.join(d, 'loose', 'AGENTS.md'), '# parent rules\n');
register(opts(d, 'hermes', { env, context: ctx }));
expect(fs.readFileSync(ctx, 'utf8')).toContain(BLOCK_BEGIN);
}, 120_000);
});
+109
View File
@@ -0,0 +1,109 @@
/**
* GSTA-135 test-run marker: harness eval adapters export
* GSTACK_SESSION_KIND=spawned (never GSTACK_CAPY) so trials skip onboarding.
* No classifier change rides with it, so two things are pinned here: both
* install-admission twins treat a marker-only environment the same way (soft:
* they apply, warn on a bad runtime, never refuse), and every gstack file that
* reads the variable is one of the known readers below, so a new reader cannot
* quietly change what the marker means.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkInstallAdmission } from '../lib/install-verdict';
const ROOT = path.resolve(import.meta.dir, '..');
const BUN = fs.realpathSync(process.execPath);
/** Every file under bin/, lib/, hosts/ and scripts/ whose code reads or sets GSTACK_SESSION_KIND, with the line it does it on. */
const SESSION_KIND_INVENTORY: Record<string, string> = {
'bin/gstack-session-kind': 'step 0: the explicit marker outranks every ambient signal (:41-43)',
'bin/gstack-skill-start': 'unknown-kind note (:99-101) and SPAWNED_OVERRIDE (:205-206); _NO_HUMAN keys on the resolved kind (:107-108)',
'bin/gstack-install-verdict.sh': 'admission: spawned refuses only with GSTACK_CAPY=1 (:117)',
'lib/install-verdict.ts': 'the TypeScript twin of the same admission rule (:183-188)',
'bin/gstack-plan-reality': 'records the kind (:58)',
'bin/gstack-ship-receipt': 'records the kind (:143, :322)',
'lib/autoplan-run.ts': 'records the kind (:110)',
'bin/gstack-brain-enqueue': 'an unattended session never spools for sync (:38)',
'hosts/claude/hooks/spawned-directive.ts': 'Claude Code only (:57)',
'hosts/claude/hooks/question-preference-hook.ts': 'Claude Code only (:506)',
'bin/gstack-capy-install': 'writer: the Capy launcher exports unattended (:658, :677)',
'scripts/cso-eval-producer.ts': 'writer: sets spawned for its own producer run and restores it (:616-631)',
'scripts/lib/shard-engine.ts': 'writer: drops the Capy launch markers from shard environments (:595)',
};
const CODE_FILE = /\.(ts|tsx|js|mjs|cjs|sh|bash)$/;
/** Files under root/dirs whose code (not comments) names GSTACK_SESSION_KIND, as root-relative paths. */
function sessionKindReaders(root: string, dirs = ['bin', 'lib', 'hosts', 'scripts']): string[] {
const walk = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === 'node_modules' || e.name === 'dist' ? [] : walk(p);
return e.isFile() ? [p] : [];
});
return dirs.filter(d => fs.existsSync(path.join(root, d))).flatMap(d => walk(path.join(root, d))).filter(file => {
const text = fs.readFileSync(file, 'utf8');
if (!CODE_FILE.test(file) && !text.startsWith('#!')) return false;
const code = (/\.(sh|bash)$/.test(file) || /^#!.*\b(ba)?sh\b/.test(text) ? text : text.replace(/\/\*[\s\S]*?\*\//g, ''))
.split('\n').filter(l => !/^\s*(#|\/\/|\*)/.test(l));
return code.some(l => l.includes('GSTACK_SESSION_KIND'));
}).map(f => path.relative(root, f)).sort();
}
describe('marker-only test runs (GSTACK_SESSION_KIND=spawned, no GSTACK_CAPY)', () => {
const marker = { GSTACK_SESSION_KIND: 'spawned' };
test('gstack-session-kind resolves the marker to spawned without any harness signal', () => {
const r = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-session-kind')], { env: { PATH: '/usr/bin:/bin', HOME: os.tmpdir(), ...marker }, encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim()).toBe('spawned');
});
test('both install-admission twins give the same soft outcome: they apply, warn on a runtime below the minimum, never refuse', () => {
const sr = fs.mkdtempSync(path.join(os.tmpdir(), 'marker-only-'));
try {
const fakeBin = path.join(sr, 'old-bun');
fs.mkdirSync(fakeBin);
fs.writeFileSync(path.join(fakeBin, 'bun'), '#!/bin/sh\necho 1.3.14\n', { mode: 0o755 });
const bash = (bunDir: string) => {
const r = spawnSync('bash', ['-c', '. "$R/bin/gstack-bun-version.sh" && . "$R/bin/gstack-doctor-components.sh" && . "$R/bin/gstack-install-verdict.sh" && gstack_install_admission "$R/bin" review spawned; printf "%s|%s|%s|%s" "$_ia_applies" "$_ia_refuse" "$_ia_warn" "$_ia_status"'],
{ cwd: sr, env: { PATH: bunDir + ':/usr/bin:/bin', HOME: sr, R: ROOT, _GH: sr, GSTACK_STATE_ROOT: sr, ...marker }, encoding: 'utf8', timeout: 30_000 });
const [applies, refuse, warn, status] = r.stdout.trim().split('|');
return { applies: applies === '1', refuse: refuse === '1', warn: warn === '1', status };
};
const ts = (bunVersion: string) => {
const a = checkInstallAdmission({ skill: 'review', gstackRoot: ROOT, cwd: sr, bunVersion, bunPath: BUN, env: { GSTACK_STATE_ROOT: sr, ...marker } });
return { applies: a.applies, refuse: a.refuse, warn: a.warn, status: a.status };
};
const working = { applies: true, refuse: false, warn: false, status: 'ok' as const };
expect(bash(path.dirname(BUN))).toEqual(working);
expect(ts('1.4.2')).toEqual(working);
const old = { applies: true, refuse: false, warn: true, status: 'runtime' as const };
expect(bash(fakeBin)).toEqual(old);
expect(ts('1.3.14')).toEqual(old);
} finally { fs.rmSync(sr, { recursive: true, force: true }); }
});
});
describe('GSTACK_SESSION_KIND readers ratchet (bin/, lib/, hosts/, scripts/)', () => {
test('every file whose code reads or sets the marker is in the inventory, and every inventory entry still does', () => {
expect(sessionKindReaders(ROOT)).toEqual(Object.keys(SESSION_KIND_INVENTORY).sort());
});
test('the scan counts code, not comments, and catches a planted reader', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kind-ratchet-'));
try {
fs.mkdirSync(path.join(root, 'lib'));
fs.mkdirSync(path.join(root, 'bin'));
fs.writeFileSync(path.join(root, 'lib', 'commented.ts'), '// GSTACK_SESSION_KIND only in a comment\n/*\n * GSTACK_SESSION_KIND\n */\nexport const x = 1;\n');
fs.writeFileSync(path.join(root, 'bin', 'commented'), '#!/usr/bin/env bash\n# GSTACK_SESSION_KIND in a comment\necho ok\n');
fs.writeFileSync(path.join(root, 'lib', 'notes.json'), '{"note": "GSTACK_SESSION_KIND"}\n');
expect(sessionKindReaders(root)).toEqual([]);
fs.writeFileSync(path.join(root, 'lib', 'reader.ts'), 'export const kind = process.env.GSTACK_SESSION_KIND;\n');
fs.writeFileSync(path.join(root, 'bin', 'reader'), '#!/usr/bin/env bash\n[ "${GSTACK_SESSION_KIND:-}" = spawned ] && exit 0\n');
expect(sessionKindReaders(root)).toEqual(['bin/reader', 'lib/reader.ts']);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* lib/skill-triggers.ts: the trigger-first wording rule for harness listings
* (Hermes description leads, router lines), on its branches the full-catalog
* render tests in test/harness-register.test.ts never reach because every
* current skill has a trigger sentence and a triggers list.
*
* Value: protects=Hermes descriptions keep every non-trigger sentence and the (gstack) tag; router/lead fallbacks stay trigger-first and within max;
* fails_when=withTriggerLead drops the wrong sentence or the tag, or a fallback branch (no trigger sentence, no triggers, overlong entry) exceeds max or loses its lead;
* why_new=the render tests check only the lead's prefix and length over skills that all take the triggers-list branch; seam=none;
* no_claim=how any harness ranks or shows the text (pure string functions; synthetic descriptions)
*/
import { describe, expect, test } from 'bun:test';
import { frontmatterList, routerLine, triggerList, withTriggerLead } from '../lib/skill-triggers';
const DESC = 'Pre-landing PR review. Analyzes the diff for structural issues. Use when asked to "review this PR" or "check my diff". Proactively suggest before a merge. (gstack)';
const TRIGGERS = ['review this pr', 'code review', 'check my diff', 'pre-landing review'];
describe('withTriggerLead (Hermes descriptions)', () => {
test('a triggers-list lead replaces the trigger sentence; every other sentence and the (gstack) tag stay, once', () => {
const [lead, rest, ...more] = withTriggerLead(DESC, TRIGGERS, 57).split('\n');
expect(more).toEqual([]);
expect(lead).toStartWith('Use for: review this pr');
expect(lead.length).toBeLessThanOrEqual(57);
expect(rest).toBe('Pre-landing PR review. Analyzes the diff for structural issues. Proactively suggest before a merge. (gstack)');
});
test('without triggers the lead is the trigger sentence cut at a word, and the whole description follows unchanged', () => {
const [lead, ...rest] = withTriggerLead(DESC, [], 40).split('\n');
expect(lead).toStartWith('Use when asked to');
expect(lead).toEndWith('...');
expect(lead.length).toBeLessThanOrEqual(40);
expect(rest.join('\n')).toBe(DESC);
});
});
describe('routerLine (router-mode targets)', () => {
const cases: Array<[string, string, string[], number, (line: string) => void]> = [
['trigger sentence and first sentence both fit', 'Debug a crash. Use to find the root cause.', [], 160,
l => expect(l).toBe('Use to find the root cause. Debug a crash.')],
['trigger sentence alone when both do not fit', DESC, [], 60,
l => { expect(l).toStartWith('Use when asked to'); expect(l).not.toContain('Pre-landing'); }],
['no trigger sentence: the triggers list leads', 'Pre-landing PR review. Analyzes the diff.', TRIGGERS, 160,
l => expect(l).toStartWith('Use for: review this pr, code review')],
['neither: the first sentence, cut at a word', 'A very long first sentence about a workflow that goes on and on and on.', [], 40,
l => { expect(l).toStartWith('A very long first sentence'); expect(l).toEndWith('...'); }],
];
for (const [name, desc, triggers, max, check] of cases) {
test(name, () => {
const line = routerLine(desc, triggers, max);
expect(line.length).toBeLessThanOrEqual(max);
check(line);
});
}
});
test('triggerList: an entry longer than max is cut to fit instead of overflowing', () => {
const lead = triggerList(['find out why the nightly deploy pipeline keeps failing on the staging cluster'], 57);
expect(lead).toStartWith('Use for: find out why');
expect(lead).toEndWith('...');
expect(lead.length).toBeLessThanOrEqual(57);
});
test('frontmatterList reads one block list, unquoting values and stopping at the next field', () => {
const md = '---\nname: x\ntriggers:\n - "quoted one"\n - \'single\'\n - plain\nallowed-tools:\n - Bash\n---\nbody\n - not frontmatter\n';
expect(frontmatterList(md, 'triggers')).toEqual(['quoted one', 'single', 'plain']);
expect(frontmatterList(md, 'voice-triggers')).toEqual([]);
expect(frontmatterList('no frontmatter\ntriggers:\n - x\n', 'triggers')).toEqual([]);
});