mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
* chore(design): pin impeccable rule ids and detector JSON shape as fixtures Real captures from a human-initiated `npx impeccable install` in a scratch directory (engine 0.1.3, linux-x64), never a runtime download: - test/fixtures/impeccable-antipatterns.json: upstream crates/live/assets/antipatterns.json at 87d8f6d6 (the state engine-v0.1.3 shipped), 61 rules, source commit recorded in `_source`. - test/fixtures/impeccable-detect-sample.json: `detect --json` over gstack's planted-slop fixture (source mode), paths normalized. - test/fixtures/review-eval-design-slop.dom.html + impeccable-detect-dom-sample.json: the same page served locally, dumped through the browse engine with the shared DOM-dump script, then scanned. Pins the load-bearing assumption that the static engine reads inline <style> in a .html file: the DOM scan yields the same id set as the source scan. - lib/dom-dump-script.ts: the one dump script both browser engines evaluate (IIFE, no single quotes). Folds CSSOM rgb() back to author hex so palette rules still fire, and removes inlined <link> nodes so the engine does not warn about an unresolvable stylesheet. Both verified against the engine. - test/fixtures/impeccable-detect-help.txt + impeccable-captures.meta.json: the flags, exit codes, finding fields, and re-capture protocol. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(design): typed slop catalog in lib/; AI_SLOP_BLACKLIST derived lib/design-catalog.ts is the single source of truth for gstack's design anti-pattern vocabulary: the 11 legacy blacklist lines (verbatim, flagged `legacyBlacklist`), every one of impeccable's 61 registry ids with gstack prose, tier, impact, confidence, grep heuristic, and /impeccable handoff, plus the gstack-only tells the LLM pass judges (hero metrics, identical cards, glassmorphism, missing states, unthemed browser surfaces, ...). `impeccableId` is set only when the id exists in the registry fixture, and `renderCatalog({style:'ids'})` brackets an id only then, so rendered prose never shows an id the detector cannot emit. Role-scoped font lists (OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE) live beside the entries. scripts/resolvers/constants.ts now derives AI_SLOP_BLACKLIST from the catalog. Generated output is byte-identical (bun run gen:skill-docs is a zero diff). Pure module: no I/O, no scripts/ imports, loading prints nothing, so bin/ can import it at runtime on every host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(review): generate review/design-checklist.md from the catalog review/design-checklist.md was hand-written and its own header admitted it drifted from DESIGN_METHODOLOGY category 9. It is now rendered by scripts/resolvers/design-checklist.ts from lib/design-catalog.ts: category 1 lists every grep-detectable slop entry plus the legacy blacklist lines, sorted HIGH/MEDIUM/LOW, each with its heuristic and, where the detector knows the rule, its bracketed id (27 items, up from 6). The font blacklist renders from BANNED_FONTS. Categories 2-5, Instructions, Classification, Output Format, and Suppressions keep their prose. Title and slop heading are unchanged (test/skill-e2e-review.test.ts and hosts/opencode.ts key on them). gen-skill-docs writes the file for the Claude host only (a Claude-side runtime asset; other hosts copy or inline the render), honors --out-dir, and reports STALE/FRESH under --dry-run like sections do. test/design-checklist-sync.test.ts pins committed == generated, the host/out-dir scoping, and the dry-run freshness line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): modes, craft-floor reflexes, calibration, catalog in doctrine DESIGN_HARD_RULES: the classifier names four visitor modes (Persuade, Operate, Read, Experience, plus Hybrid per section) and keeps the MARKETING/LANDING PAGE and APP UI aliases; Read and Experience get three rules each; a "Reflexes no detector catches" block (browser surfaces, one authored motion moment, depth has an offset, tinted secondary text, space above headings, light-or-dark from the use scene) and the three-looks calibration follow the universal rules. The slop section renders the 11 legacy lines plus the detector rule ids and judgment tells from the catalog; in design-review, which also renders DESIGN_METHODOLOGY, it becomes a one-line pointer so the catalog is paid for once. Header counts are computed, not hardcoded. DESIGN_METHODOLOGY: category 9 renders the catalog in three registers (legacy lines verbatim, detector rules that need judgment with bracketed ids, gstack-only judgment tells as prose, polish-level ids on one line); categories 5 and 7 carry the browser-surface and one-motion-moment reflexes; the typography overused-face item points at [overused-font] with the role-scoped exception. The consultation Codex prompt's anti-slop line reads from the catalog. Budget: design-review eager 25.6K -> 27.0K (ceiling 27,984), plan-design-review unchanged at 17.4K; no carve-guard or context-budget re-baseline needed; ship goldens unchanged (ship never renders the hard rules). Derived from pbakaus/impeccable reference/craft-floor.md + new-work.md (Apache-2.0), rewritten. See NOTICE.md (commit 12). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design-consultation): font procedure, role-scoped overused list, color strategies The proposal section stops handing out a font menu. "Choosing faces: a procedure, not a menu" names the subject's world, shortlists per role, strikes the overused list for that role, verifies availability in-session, and states the loading strategy. {{OVERUSED_FONTS}} renders the role-scoped lists from lib/design-catalog.ts: overused as display (the detector's overused-font set plus the training-data defaults), fine as body/UI on an Operate or Read surface, mono for data and code, banned in any role, and a short verified-free list with its verification date. Color approaches become Restrained / Committed / Full palette / Drenched. The anti-convergence directive drops light-vs-dark as a dial (it comes from the use scene) and the three-looks calibration sits under Your Design Knowledge. The slop list is {{DESIGN_SLOP_BULLETS}}: prose from the catalog, no rule ids, polish-level tells omitted. design-html's "Never include (AI slop blacklist)" list keeps its literal (carve guard) and each line now carries a trailing <!-- id --> naming a catalog entry, pinned by test/design-catalog.test.ts so the last surviving duplicate is derived-by-test. Both resolvers are registered and listed in ARCHITECTURE.md. No carve-guard or budget re-baseline needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(bin): gstack-design-detect wrapper + design_detector config key bin/gstack-design-detect.ts finds and runs an impeccable engine the user installed; it never installs, downloads, or executes anything that could download. `probe` reads only: config (design_detector off → DISABLED), IMPECCABLE_BIN (absolute, realpath outside the repo and cwd), a PATH walk (absolute entries outside the repo; a #! shim counts as launcher-present, never READY), the ~/.impeccable/bin/<newest semver>/ cache, and the engine installed beside a skill launcher (scripts/bin/<os>-<arch>/impeccable, the layout a real install produced). It reports IMPECCABLE_SKILL, host-aware IMPECCABLE_HOOK (+ HOOK_OTHER), the ignore lists from .impeccable/config*.json, IMPECCABLE_ENGINE_UNTESTED for versions outside the fixture set, and a hint only when a launcher exists without its engine. `scan` re-probes, refuses URLs and anything outside the repo root or the design-report allow-list (realpath, so symlinks cannot escape), derives `--changed <base>` targets NUL-safely through git and lib/frontend-scope.ts, batches 100 absolute paths per engine call with stdin ignored, a SIGKILL timeout, a 50 MB stdout cap, and sanitized length-capped fields, then prints one normalized JSON document (--format gstack) or the engine's bytes (--format raw); DETECT_TOP (fenced as untrusted content), DETECT_SUMMARY, and DETECT_EXIT go to stderr; exit code passes through with 1 over 2 over 0; exit 3 is a gstack bug. `rules` prints the mapped set. Every run appends a content-free line to the local analytics file. lib/design-detect-contract.ts owns every sentinel string, the limits, and the normalized-finding shape (pure module); test/design-detect-contract.test.ts asserts every sentinel-shaped token the agent can read exists there. lib/frontend-scope.ts mirrors gstack-diff-scope's frontend arm, pinned by a parity test that runs the bash script. bin/gstack-config gains design_detector (auto | off, default auto, invalid values rejected with the file unchanged). test/fixtures/fake-impeccable.ts is the env-driven engine stand-in; test/gstack-design-detect.test.ts covers READY/NOT_CACHED/ NOT_AVAILABLE/DISABLED, env trust (.env never loaded, in-repo IMPECCABLE_BIN ignored), newest-semver cache, hook and ignore detection, refusals, exit passthrough, raw byte-identity, normalization, the display cap, timeout, parse errors, diagnostics, --changed, and analytics. The egress scanner test records the wrapper as a documented non-sink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): {{DESIGN_DETECTOR}} wired into design-review, ship review-lite, review army, design-html The user-installed impeccable engine becomes a deterministic pre-pass in four skills, through one resolver with three renders: {{DESIGN_DETECTOR}} (the probe block and how to read every sentinel), {{DESIGN_DETECTOR:phase0}} (design- review's mechanical scan), {{DESIGN_DETECTOR:gate}} (design-html's bounded slop gate). Every rendered invocation is `bun --no-env-file run <bin>/gstack-design- detect.ts ... --host <host>` and every scan ends with the DETECT_EXIT_CODE echo so exit 2 (findings) never aborts a block. design-review: probe in Setup; Phase 0 picks DOM mode (URL target) or source mode (diff-aware, no URL) once; source mode scans the changed frontend files in Setup, DOM mode never reads source (Rule 4). Phase 3 gains a DOM-dump step per page: both browser engines load the shared script from lib/dom-dump.js (Aside splices it into a double-quoted repl script; the fallback engine copies it into a temp dir for `$B eval --out --raw`), the dump is size-capped, run through gstack-redact (a HIGH finding skips the page), and persisted under $REPORT_DIR/dom/$RUN_ID/; one scan runs after the last page, labeled "static scan of the rendered DOM; cross-origin CSS not resolved". REPORT_DIR honors GSTACK_HOME so the wrapper's allow-list and the report dir agree; RUN_ID is set once in Setup. design-baseline.json is schemaVersion 2 with runId, targetSet, base, and a detector block (mode, engine, byRule, byPage), written temp+rename with a per-run copy; Regression Output diffs ids only when mode and target set match, caveats an engine change, and calls live-page count deltas advisory. Phase 7 hands deferred detector findings to the `handoff=` command the scan printed; Phase 9 recomputes the same way and deletes the dumps unless --keep-dom; Phase 10 reports `Detector: N → M`. ship review-lite gains step 0 (probe, `scan --changed <base>`, tier buckets, detector + checklist dedupe, advisory and ignored never count) and a `detector` count in its log payload; the PR body gets a Detector line (rule ids and counts only). The Review Army Design specialist runs the mechanical pass at the top of review/design-checklist.md, which now carries it. design- html probes after DESIGN_SETUP and runs the one-pass gate before screenshots. lib/dom-dump.js is generated by gen-skill-docs from lib/dom-dump-script.ts (Claude host, --out-dir aware, dry-run freshness) and pinned byte-equal, so the prose never carries the script. The contract gains DETECT_JSON, DOM_DUMP_OK, and the self-describing set; its test now checks both directions. Budget: design-review eager 25.6K → 28.5K. The plan's target was +2.5K; after the levers it named (ids-only detector rules, no inline script, trimmed prose) it lands at +2.87K, and the remainder is doctrine and detector wiring, so the ceiling moves to the captured 31,319 for design-review only (the full capture would also have loosened 21 ceilings this branch never touched; those stay). design-html skeleton re-baselined to 54,000 (measured 53,592). Codex and Factory ship goldens refreshed (review-lite step 0 and the PR-body line render inline there). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): catalog never-lines in the mockup prompt Ten catalog ids carry `mockupNever` (kicker-above-heading, icon-tile-stack, gradient-text, ai-color-palette, cream-palette, nested-cards, dark-glow, pulsing-dot, identical-cards, hero-metrics) and lib/design-catalog.ts exports their deduped plain-English names as MOCKUP_NEVER_NAMES. briefToPrompt() in the design binary appends "Never: <names>." before its fixed tail, so `$D generate | variants | evolve` stop reaching for purple gradients, icon tiles, and cream defaults before the comparison board opens. The binary still bundles (`bun build --compile design/src/cli.ts`); ./setup rebuilds it. design-html's Never-include list now covers every mockupNever id (kicker / icon tile, hero metric rows, gradient text, cream palette, nested and identical cards, glow and pulsing dots), each line tagged with its catalog ids; test/design-catalog.test.ts pins the exact ten flags, the deduped names, and that the template list is a superset. New design/test/brief.test.ts pins the prompt shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(lib): open DESIGN.md reader/writer + gstack-design-md bin lib/design-md.ts implements the open DESIGN.md format (google-labs-code/ design.md, Apache-2.0): YAML front matter with the five token groups (colors, typography, rounded, spacing, components) and eight canonical `##` sections in spec order (Overview, Colors, Typography, Layout, Elevation & Depth, Shapes, Components, Do's and Don'ts), aliases mapped, extras preserved after them in their original order. parseDesignMd never throws (unparsable front matter → `unknown` with a reason); renderDesignMd re-emits the preserved front matter bytes and only `convert` writes fresh YAML through a small block-style emitter (Bun.YAML.stringify is flow style); upsertSection splices the body only; tokensFlat resolves `{path}` references to primitives and reports group, self, dangling, and cyclic refs as DESIGN_MD_TOKEN_REF_INVALID. convertLegacy turns gstack's pre-spec DESIGN.md into the open format: Product Context and Aesthetic Direction fold into Overview, Typography roles become display/body/label/mono tokens (mono carries fontFeature: tnum), Color hexes become colors (mode-qualified labels keep their qualifier; strategy lines are not colors), the Spacing scale and Layout radii become spacing and rounded, Motion / Grain Texture / Decisions Log survive as extras. The format marker lives inside the file: a YAML comment on line 2 of a spec file, an HTML comment on line 1 of a legacy file. bin/gstack-design-md.ts: `check` (DESIGN_MD_FORMAT + marker), `convert [--write]` (backup to DESIGN.md.legacy.bak, temp+rename, refuses ambiguous input with DESIGN_MD_CONVERT_REFUSED), `tokens` (flat JSON), `mark <spec|legacy-keep>`. Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. design/src/memory.ts: updateDesignMd upserts "Extracted Design Language" through the lib (front matter bytes untouched, canonical order kept, section replaced on rerun) and creates a spec skeleton with tokens from the extraction when no file exists; readDesignConstraints leads with the flat tokens and the Overview for spec files. The design binary still bundles. test/design-md.test.ts pins all of it against gstack's own DESIGN.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): write/read DESIGN.md in the open spec; persisted format choice gstack's design skills now write DESIGN.md in the open DESIGN.md format and read tokens from it. {{DESIGN_MD_CHECK}} renders the format check through bin/gstack-design-md.ts: design-consultation's Phase 0 settles the format once (spec → update tokens in the front matter; legacy without a marker → one AskUserQuestion: convert with a .legacy.bak, keep the legacy file, or start fresh; the answer is written into the file as the format marker so no skill asks again; a marker already present is obeyed silently; unknown → prose; missing → Phase 6 writes one). Phase 6's template is the spec form: YAML front matter with name, description, and exactly the five token groups (colors, typography.display/body/label/mono with fontFeature: tnum on mono, rounded, spacing, components with {path} references), then Overview (Creative North Star, product context, mode per surface, references, key characteristics), Colors (opening with the Restrained / Committed / Full palette / Drenched strategy), Typography, Layout, Elevation & Depth, Shapes, Components, Do's and Don'ts, plus gstack's Motion and Decisions Log as extras; the template ends with a check that the file parses as `spec`. design-review runs the `:calibrate` form in Setup: a spec file's flat tokens are the calibration source (a value present in the tokens is never a finding), the marker is respected, and conversion is never offered there; its DESIGN.md export writes the spec form. design-html's token extraction writes the spec form and respects an existing choice. review/design-checklist.md category 5 and ship's review-lite step 1 name `gstack-design-md tokens` as the calibration source; plan-design-review Pass 5 cites tokens by path when front matter exists. The contract owns the bin's DESIGN_MD_MARKER / REASON / WRITTEN / BACKUP lines; the contract test's pending list closes. Carve guard: design- consultation skeleton 66,500 → 67,500 (measured 67,014; +1,508 B against the 1.5 KB cap). Codex and Factory ship goldens refreshed (review-lite step 1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): PRODUCT.md prefill + /impeccable handoffs design-consultation's context gathering and design-shotgun's auto-gather read PRODUCT.md (impeccable's product-context file) when it exists: it counts as the user's prior answers, gets confirmed in one line, and is never re-asked. Neither skill opens `.claude/skills/impeccable/**`; PRODUCT.md and DESIGN.md are the shared surface, and impeccable's prose never loads inside a gstack skill. Handoffs: ship's review-lite ends each NEEDS INPUT detector row with the `handoff=` command the scan printed (`/impeccable <cmd>`) when the probe reported IMPECCABLE_SKILL: present, recommending the command and never opening its files; design-review's Phase 7 does the same for deferred findings, and `design_detector: off` silences handoff lines with the rest. Codex and Factory ship goldens refreshed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(design): convert gstack's own DESIGN.md to the open spec `gstack-design-md convert --write` on the repo's DESIGN.md: tokens in YAML front matter (typography.display/body/label/mono, colors with their light/dark qualifiers, spacing scale, rounded scale), Overview from Product Context and Aesthetic Direction, Colors / Typography / Layout as canonical sections, Motion, Grain Texture, and Decisions Log preserved as extras, format marker on line 2. Hand-checked; `check` reports spec with no token-reference errors. A Decisions Log row records the conversion and that DM Sans stays the body face: it is on the overused-as-display list, and body/UI use on an Operate surface is the allowed exception under the role-scoped rule. The pre-conversion file lives on as test/fixtures/design-md-legacy.md, which test/design-md.test.ts now uses for its legacy cases; the converted root file is asserted to be spec. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: NOTICE, Apache license text, README interop, project structure NOTICE.md names what gstack derived from impeccable (rule ids and names in the catalog and the registry fixture; the visitor modes, craft-floor reflexes, and calibration in the design resolvers; the font procedure in the consultation template) and from Google's DESIGN.md specification (the format lib/design-md.ts implements), states that gstack does not distribute or audit the impeccable engine, and points at licenses/Apache-2.0.txt (verbatim). README: the design-consultation, design-review, and design-html rows say what changes when impeccable or the open DESIGN.md format is in play, and a "Works with impeccable" paragraph explains the pre-pass, the shared ids, PRODUCT.md and DESIGN.md as the shared surface, the handoffs, the no-nag posture without impeccable, and the off switch. docs/skills.md gets the detector paragraph under /design-review. docs/PROJECT_STRUCTURE.md lists the new lib and bin files, NOTICE.md, and licenses/. docs/designs/IMPECCABLE_INTEROP.md promotes the CEO plan (its ~/.gstack copy is flipped to PROMOTED) with a "what shipped" summary. TODOS.md files the seven deferrals from the reviews: the design-review Phases 7-11 carve (the budget lever, with the +2.87K vs 2.5K landing recorded), the Bun .env audit across bin/*.ts, the Kiro bin/lib gap, the $D check slop rubric, taste-profile interplay, the CEO Section 11 bullets, and the scan cache. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: touchfiles, tiers, shim E2E, real-engine fixture Touchfiles: the catalog, contract, detector bin, checklist resolver, review- army resolver, and DESIGN.md lib join the dep lists of review-design-lite, design-review-fix, the design-consultation cases, and plan-design-review-no- ui-scope, so editing any of them re-selects the tests that read their output. Three new E2E keys: design-review-detector-shim (gate; source mode on a feature-branch diff), design-review-detector-shim-dom (gate; DOM mode: the slop fixture served on loopback, dumped through the browse binary with lib/dom-dump.js, persisted under a GSTACK_HOME-scoped REPORT_DIR, scanned once; self-skips when browse/dist/browse is absent), and design-html-slop-gate (periodic; one fix pass, at most two scans, remaining findings accepted with reason). Every case reaches the engine through test/fixtures/fake-impeccable.ts via IMPECCABLE_BIN from outside the temp repo, reads extracted skill sections (never a whole SKILL.md) with the installed bin path rewritten to this checkout, and asserts the probe ran, the right scan verb ran, `npx impeccable` never did, and the output carries FINDING rows tagged [ai-color-palette] and [low-contrast]. review-design-lite gets the fake engine and an eighth tally signal for a detector row; its 4-hit threshold is unchanged. test/gstack-design-detect.test.ts evaluates design-review's REPORT_DIR expression with GSTACK_HOME set and proves a dump under it is accepted by the wrapper's allow-list. The sample fixtures were real captures from commit 1 (engine 0.1.3), so there is nothing hand-written left to swap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-detect): never execute a repository-controlled engine; allow-list --changed targets; sanitize engine text Pre-landing review findings (security + checklist), all reproduced before the fix: - A checked-out branch could commit `.claude/skills/impeccable/scripts/bin/<os>-<arch>/impeccable` and the probe would report READY and `scan` would run it, with the agent's full environment. Launchers and sibling engines under the repo or cwd now count as "skill present" only (IMPECCABLE_NOT_CACHED: repository-local install, and the hint never names a repository-local launcher to run); only HOME-rooted installs, IMPECCABLE_BIN, the cache, and PATH entries outside the repo qualify, all by realpath. The engine now sees a minimal environment (PATH, HOME, TMPDIR, locale, IMPECCABLE_*), never the agent's tokens. - `scan --changed <base>` pushed git-derived paths without the allow-list, so a committed symlink with a frontend extension handed a file outside the repo to the engine. Derived targets now go through the same allow-list as explicit ones and symlinks named by git are refused outright. - A repo-controlled `scripts/VERSION` with embedded newlines forged probe lines; the version is trusted only when it is semver, and every printed version is sanitized. Engine text containing the untrusted-content fence or a `SENTINEL:` prefix is neutralized with a zero-width space (neutralizeSentinels in the contract), so page text cannot close the envelope or forge a probe line. - A failing `git diff <base>...HEAD` (unknown or unfetched base) was swallowed and read as "no frontend changes"; it is now DETECT_REFUSED with exit 1. - The scan allow-list root follows `${GSTACK_HOME:-$HOME/.gstack}` like the templates and gstack-slug (config.yaml keeps gstack-config's STATE_ROOT precedence); a quoted or commented design_detector value reads correctly. Smaller: raw engine chunks are kept only in --format raw; diagnostics are capped (200 kept, 20 echoed); the engine identity hash reads size + 4 MB, not the whole binary; PROBE_STEP and ENGINE_STDERR are contract sentinels; the --verbose gate covers every probe step; analytics use one sentinel vocabulary; bare limits live in DETECT_LIMITS. The fake engine's knobs are IMPECCABLE_FAKE_* (so they pass the minimal env) and a shared test helper installs it. New tests cover each item above plus clean runs, `{}` parse errors, missing paths, and the 50 MB stdout cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-md): mark and updateDesignMd never rewrite the user's file; refuse a contradictory mark renderDesignMd re-sorted canonical section names into spec order on every render, so `gstack-design-md mark legacy-keep` (the "leave it alone" answer) and the design binary's mockup extraction reordered a legacy DESIGN.md (Typography and Layout jumped to the top) and normalized its whitespace, while the bin promised "body bytes untouched". `mark` now splices only the marker line (insertMarker) and `updateDesignMd` splices only its own section (spliceSection); every other byte of an existing file is preserved, and spec order applies only to files that open with front matter. `mark` refuses a choice that contradicts the file's format (spec on a non-spec file, legacy-keep on a spec file) with DESIGN_MD_CONVERT_REFUSED, exit 2, file unchanged. convertLegacy keeps intro prose under the title instead of rebuilding the preamble from the title alone. detectFormat returns a machine-readable `code` beside the prose reason (the bin no longer branches on reason text); the marker regexes derive from FORMAT_MARKER_PREFIX and FORMAT_CHOICES; the hop limit and legacy identity headings are named constants; slug is exported and reused; both writers use lib/fs-atomic.ts. Tests pin byte identity for mark and updateDesignMd on the legacy fixture, the refusal paths, and the preserved preamble. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design): run the DOM dump in the page on both engines; align doctrine with the catalog The DOM-dump script is an arrow function, not a self-calling IIFE: Aside's `pg.evaluate($_DUMP)` receives the function and runs it in the page (the IIFE form executed in the repl sandbox, where `document` does not exist), and the fallback engine calls it with `$B js "($_DUMP)()" --out --raw`. Hygiene widens to every URL-bearing attribute (src, srcset per candidate, poster, action, formaction, data, ping, cite lose their query strings and fragments) and to data: URLs inside existing <style> nodes. The persist and scan blocks restate REPORT_DIR and RUN_ID literally instead of relying on a shell variable from an earlier block; the baseline's targetSet is defined per mode (repo-relative paths in source mode, page slugs in DOM mode) so DOM-mode deltas can match; the PR-body Detector line lists the states the probe can actually print. The DOM fixture is re-captured with the new script from outside the repo (the engine walks up from cwd for DESIGN.md, which the metadata now records). Doctrine contradictions the design specialist found: the landing-page motion rule matches the one-authored-moment reflex; the background rule names the catalog's halo/spotlight/stripe/grid slop instead of asking for gradients; the universal font rule is scoped to the display voice with the body/UI exceptions; "two typefaces max" allows the mono; the methodology's banned-font line renders BANNED_FONTS; Courier New is banned outright; the Brutalist, Retro-Futuristic, and Playful menu entries stop recommending system stacks, glow, and bounce; the coherence nudge uses the decoration vocabulary; Path A's gate names the display voice; font-loading prose points at the source the procedure verified; centered-everything is MEDIUM (an aggregate heuristic); the mockup guard reads "Never by default (unless the brief above asks for it)". The checklist's AUTO-FIX list renders the catalog's auto-fix rules; category 9 and the Hard Rules pointer count from the same partition helpers (detectorSlopEntries, judgmentTellEntries); the handoff list renders from HANDOFF_COMMANDS; a missing catalog id fails gen-skill-docs by name. gstack's own DESIGN.md gains border tokens and Decisions Log rows for its live-feed pulse and 11px mono labels. frontend-scope is case-sensitive like the bash arm. gen-skill-docs shares one emitGenerated helper for sections and lib-derived assets; renderCatalog keeps the one style with a caller. Tests: shared sliceBetween that fails on a missing end marker, the slop-gate fixture's real end marker, an isolated browse daemon for the DOM-mode E2E, the DOM hygiene test gated to CI or opt-in, docs notes for the two superseded plan sentences. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-detect): an engine is a file named impeccable outside the project; DOM dumps scan without inline ignores Second review cycle, security + checklist: - IMPECCABLE_BIN=/bin/sh (or node) was READY, and `detect` with cwd=repoRoot made the interpreter run the repository's own `detect` file. Every engine candidate (env override, PATH entry, cache, sibling) is now judged by the realpath of the FILE and must be named impeccable[.exe]; PATH and cache candidates that resolve into the repository are skipped like the others. "Inside the project" means the repository, or cwd when cwd is a project directory: HOME and its ancestors are exempt, so a URL-mode review launched from HOME still finds the HOME-rooted installs. - A base for --changed that starts with `-` was spliced into git argv (`--output=<file>` made git write a file and report no changes); an option- like or missing base is DETECT_REFUSED (not a ref name), exit 1, and the parser no longer defaults a missing value to main. - DOM dumps are the audited page's bytes, so an in-file `impeccable-disable` comment there is page-controlled: batches under the designs root run with --no-inline-ignores, repository batches keep the project's own ignores. - neutralizeSentinels covers the shapes it missed (bare sentinels such as DETECT_TOP total= and IMPECCABLE_DISABLED, the DETECT_EXIT_CODE= echo, the `[rule-id] impact=` group header) in one precompiled alternation instead of 37 replaceAll passes per field; only kept findings are normalized, and the summary's total stays the engine's count. - The minimal engine environment compares keys case-insensitively on Windows (process.env enumerates Path, SystemRoot there) and passes PATHEXT, COMSPEC, HOMEDRIVE, HOMEPATH, PROGRAMDATA. - Bare 64s move into DETECT_LIMITS; the unused SentinelName type is gone; the header states the directory-target contract (the engine's own walk). Tests: an interpreter as IMPECCABLE_BIN never runs the repo's detect file; a PATH symlink into the repository is never READY; option-like and empty bases are refused with no file written; the designs-root batch carries --no-inline-ignores and the repo batch does not; the identity label is deterministic per binary; the bare-sentinel and header shapes are neutralized; the installed fake engine works without IMPECCABLE_FAKE_OUTPUT (the helper copies the sample beside it); two tests clean up in finally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-md): text-level edits keep CRLF, one section-boundary rule, control characters quoted - insertMarker and spliceSection normalized every line ending to LF, so a CRLF DESIGN.md came back rewritten beyond the one line they promised to touch. Both detect the file's dominant line ending and restore it. - parseDesignMd and spliceSection each walked headings with their own fence tracking; they now share headingLines (and upsertSection shares headingMatches). An unclosed ``` is treated as prose for that file: it used to swallow every later section on a splice. - A token value carrying a control character (an LLM-extracted font family with an embedded newline) was emitted as a bare multi-line scalar that Bun.YAML rejects, turning a freshly written DESIGN.md into frontmatter-unparsable; needsQuotes routes it through the quoted form. - The marker-line regex variants are built once beside YAML_MARKER_RE; the dead setMarker export and a no-op ternary are gone; LEGACY_HEADINGS derives from the identity list; the header diagram names the text-level editors as the write path for user-owned files; the bin validates and prints the mark choices from FORMAT_CHOICES. Tests: CRLF round-trips for both editors, a fenced ## inside a section and an unclosed fence, and a newline-bearing scalar parsing back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design): Aside dump script stays single-quoted; redaction gate sized to the dump cap; doctrine made consistent - The DOM-dump Aside block was the only double-quoted `aside repl` script in the tree (to splice the function text), which put the agent-filled <url> inside a double-quoted bash string: a same-origin href carrying $(...) would run in the reviewer's shell when Phase 3 opened that page. The script is single-quoted like every other Aside script and the function text enters through a closed-quote segment ('"$_DUMP"'); the fallback line is `$B js '('"$_DUMP"')()'`. A free test pins that no rendered Aside script opens with a double quote. - The persist block capped dumps at 10 MiB but ran gstack-redact with its 1 MiB default, so every real page between the two was deleted as DOM_DUMP_REDACTION_BLOCKED; the gate passes --max-bytes at the dump cap and blocks on any exit other than clean (0) or MEDIUM (2), so a redaction tool that fails to run can no longer fall through to "persist". - Dump hygiene removes <template> and <noscript> subtrees (invisible to the attribute walk), inline on* handlers, and the cross-origin <link> nodes already named in the note, so the file handed to the engine references no remote stylesheet. - Doctrine: the Codex design-voice prompts said "2-3 intentional motions" against the one-authored-moment rule; the overused-display heading scoped its ban to Persuade/Experience while the catalog and hard rules ban it everywhere; design-consultation's Important Rule 4 still said "as primary"; design-html's blacklist header is now "Never include by default" with the mockup/DESIGN.md/user-ask override the catalog grants; the slop gate honors Decisions Log and Do's and Don'ts blessings like /review does; the landing "poster" line says poster in stance, not type size; the design binary's variant dials no longer flip light/dark for variety; gstack's DESIGN.md rows name data labels (UI labels stay the DM Sans token) and call the skill-bar fill and hovers functional transitions. - design-review names how the base branch is found (gh pr view, then the repo default; never main) for the source-mode scan and the diff-aware mode. - frontend-scope matches the config globs at the repo root only, like the bash arm; the parity test carries nested samples. - Cleanups: renderCatalog's stale style option, an unused import, the identity-map bannedFontNames, the checklist header's "same entries" claim, the catalog header's consumer list, the orphaned main() docstring, the plan doc's IIFE bullet. design-html's skeleton ceiling is re-measured (54,184) for the two doctrine sentences. Tests: AUTO-FIX rendering from the catalog, the E2E slice markers checked in the free suite, the hygiene cases for templates/noscript/handlers/remote links, and the review E2E counting detector rows separately from the seven checklist plants. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-detect): project means below HOME; only page dumps drop inline ignores; a whole-scan budget; prototype-safe rule counts Third review cycle + Red Team, all reproduced before the fix: - With no repository, the wrapper adopted cwd as the repo root, so a review launched from HOME (URL mode can run from anywhere) rejected every HOME-rooted install as "repository-local", reported the user's own skill install with the wrong hint, and, for targets, accepted all of HOME (~/.ssh/id_rsa scanned). A project directory is now one strictly below HOME: `git init ~` never turns the user's installs into repository files, and from HOME only the designs allow-list qualifies as a target. - --no-inline-ignores keyed on "not inside the repo", which misclassified dumps when GSTACK_HOME sits under the repo and stripped the design-html gate's own `<!-- impeccable-disable -->` from finalized.html. Targets are classified as project / dom-dump (designs/<audit>/dom/**, the page's bytes) / artifact (other designs/ files, gstack-authored); only dumps drop inline ignores. - A repository's .impeccable/config.json can hide rules from the review; detector.ignoreValues was never surfaced. The probe prints IMPECCABLE_IGNORED_VALUES beside the rules, and the prose stops calling repo-config ignores "a decision the user made". - An engine id named `constructor` corrupted byRule through Object.prototype and `__proto__` counts vanished; byRule is a null- prototype object and an id that fails the shape check is `unmapped` as a key too. - Batches ran with no total budget (10,000 un-ignored files: hours). The scan stops at 5x the per-batch timeout with DETECT_TIMEOUT and exit 1. - The scan JSON carries an `untrusted` list of the engine- and page-derived fields, so the agent reading past the fenced DETECT_TOP block is told what is evidence. - The PATH walk keeps launcher-present for a .cmd wrapper or a differently named real file (the name gate applies to READY only). Tests: probe and scan from a fake HOME (cache READY, HOME file refused, dump scanned without inline ignores), artifact vs dump batches, prototype-member ids, the whole-scan budget over 11 batches, ignoreValues surfaced, the `untrusted` field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-md): edits follow a symlinked DESIGN.md, keep the BOM and the majority line ending, refuse an unclosed fence - `mark`, `convert --write`, and the design binary's extraction replaced a symlinked DESIGN.md (a docs-site layout) with a regular file and left the real target untouched; both writers resolve the link first. - A single stray CRLF flipped a whole LF file to CRLF: the editors now keep the majority ending. A UTF-8 BOM broke format detection and ended up mid-file after `mark`; it is recognized and kept at byte 0. - Re-running `mark` on a marked file deleted the blank line after the marker (`\s*$` matched across the newline); the marker regexes use `[ \t]*`. - Fences: readers follow markdown (an unclosed fence runs to EOF); the text-level editors refuse such a file with DesignMdEditRefused (DESIGN_MD_EDIT_REFUSED) instead of splicing the wrong section, and the design binary reports that and leaves the file alone. - needsQuotes also quotes a scalar containing ` #` (an inline-comment shape parsed back as a truncated value). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design): dump hygiene covers CSS URLs, srcdoc, and handlers; dumps persist owner-only; ignore prose treats repo config as evidence - The dump script cuts query strings from CSS url() in style attributes, <style> nodes, and the inlined stylesheets (signed asset URLs), empties srcdoc, and covers background and xlink:href. - Persisted dumps are chmod 600; MEDIUM redaction findings persist (an authenticated page shows emails) and the prose says so; earlier runs' dumps are swept before the first dump of a run unless --keep-dom. - The Aside dump prose asks for `'` in a pasted URL to be percent-encoded (a bare single quote would end the script) and never to paste an unread URL. - Repo-config ignores are evidence, not settled decisions, in /review, /ship, and design-review's probe prose; the scan JSON's text fields are named as untrusted. - design-html's skeleton ceiling is re-measured (54,545); ship goldens refreshed for the checklist prose. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-detect): audit directories scan as dumps; scans print probe lines on stderr; refused base always exits 1; PATH loses project entries Adversarial review (Claude subagent): - A DIRECTORY target under designs/ (the audit dir, which the prose hands the agent as REPORT_DIR) classified as an artifact, so the engine walked its dom/ subtree WITH inline ignores honored. Any directory under designs/ is now scanned as dumps. - A scan whose probe no longer finds an engine wrote its sentinel lines to stdout and exited 0, so `scan > "$_DJ"` captured "IMPECCABLE_NOT_AVAILABLE" as the scan result and the rendered bash read a clean scan. Probe lines go to stderr on every path; stdout is the JSON document or nothing. - A refused --changed base exited 0/2 when explicit targets were also given; it folds into the exit code (1 over 2 over 0). A trailing --changed no longer defaults to main. - A hand-edited `design_detector: Off` re-enabled the detector; the value is compared case-insensitively. - The engine inherited PATH entries inside the project (a direnv .envrc adding node_modules/.bin); those are filtered like every other project path. - DOM_DUMP_MISSING names the case where the dump script wrote nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design-md): markdown edge cases: rule-opened legacy files, spaced fences, ~~~ blocks, duplicate headings, YAML 1.2 numerics - insertMarker keyed on "starts with ---", so a legacy file opening with a horizontal rule got a `# gstack:` line rendered as a heading that the parser then never read back (the conversion question re-asked every run). It keys on parsed front matter. - A closing front-matter fence with trailing spaces (`--- `) made a valid spec file `unknown`; the closer is any whole `---` line. - `~~~` fences hid nothing, so a `## ` inside one was a section boundary and a splice corrupted the fence; both fence kinds are tracked and only the same kind closes an opener. - convertLegacy silently kept the first of two `## Layout` bodies (and one of `## Color` / `## Colors`); it refuses with DESIGN_MD_CONVERT_REFUSED and the bin leaves the file and writes no backup. - needsQuotes covers 0x / 0o / .inf / .nan (YAML 1.2 numerics that changed type on round-trip); emitYamlBlock throws on an object inside an array instead of writing "[object Object]". - The design binary coerces the model's extraction JSON at the parse boundary (null names, missing arrays) so the paid call's result survives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(design): print and alternate stylesheets are not scanned as page CSS; no cross-run dump sweep; probe-state and design-system caveats in prose - The dump inlined every linked sheet's rules as active CSS, so a print sheet's 12pt black text or an alternate theme produced tiny-text and palette findings the user never sees; disabled and alternate sheets are skipped and a media-scoped sheet is wrapped in its @media block. - The cross-run dump sweep is gone: two same-day reviews shared REPORT_DIR and one run's sweep deleted the other's dumps mid-audit. Dumps stay per run, owner-only, deleted after Phase 9 unless --keep-dom (now defined in the prose), and an interrupted run's dumps wait for the user. - Prose: design-system-* rows in DOM mode compare the page to THIS repo's DESIGN.md and apply only to the repo's own app; an empty scan JSON with exit 0 means the probe state changed since Setup (read stderr); the persist block names a missing dump instead of mislabeling it as a redaction block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * v1.82.0.0: impeccable interop, detector pre-pass, open DESIGN.md format Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: update project documentation for v1.82.0.0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * changelog: name the measure behind the test-count row Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(test): drive the DOM hygiene test through Playwright Chromium directly Under the six-shard CI free suite the test's private browse daemon never answered its health probe (two minutes of retries), failed the shard, and starved two unrelated test files into failing before the runner's timeout. The test now launches the same Chromium through playwright-core and calls the dump function with page.evaluate, the way Aside's pg.evaluate does: no state file, no daemon, no health window. It self-skips when the Playwright Chromium bundle is absent. Two more hygiene rules are pinned along the way (print sheets keep their @media, alternate sheets are dropped). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(test): compare gen-skill-docs paths with forward slashes on Windows gen-skill-docs prints repo-relative paths with the OS separator, so the checklist render pins (`GENERATED: review/design-checklist.md`) failed on the Windows lane against `review\design-checklist.md`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(test): assemble the planted PEM block at runtime The quality gate scans every added line of the PR diff through gstack-redact; the redaction test's literal PEM header was a HIGH finding on our own test file. The block is now built from fragments, so the scanned file never carries a key-shaped line while the test still plants a HIGH finding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design-detect): consent-gated engine install, checksum-pinned and receipted `gstack-design-detect.ts install` is the one download gstack makes, and only after a design skill's one-time question got a yes. It fetches the engine version gstack has tested (0.1.3) for this platform from impeccable's own GitHub release, verifies it against the checksum pinned in lib/design-detect-contract.ts (all five platforms, captured from the release's .sha256 sidecars; linux-x64 equals the fixture engine), writes an egress receipt before the fetch and refuses to download when the receipt cannot be written (fail-closed; the sink is registered in the wiring test's polarity table), caps the download at 32 MB, streams with the cap enforced, writes the file only after the hash matches, and places it under ~/.impeccable/bin/<version>/ (a trusted IMPECCABLE_HOME is honored; never inside a project). No skill, no hook, no launcher, no npx. --sha256 accepts a sidecar checksum for a version gstack has not pinned; --base allows a mirror (https, or http on loopback for tests). After a successful install the probe runs and its lines follow, so the skill sees READY at once. The probe ends with DESIGN_DETECTOR_INSTALL_OFFER (version, platform, bytes, destination) whenever it found no engine and the user has not answered the question; once design_detector_install_prompted is true it prints neither the offer nor the NOT_CACHED hint, which used to repeat on every run. The hint's npx wording is corrected: `npx impeccable detect --help` caches the engine for npx only, not where the probe looks. gstack-config gains design_detector_install_prompted (true|false, typo rejected, enumerated in list and defaults). Tests: a loopback mirror (async spawn, so the in-process server can answer) covers install, re-install as a verified no-op, checksum mismatch, 404, unpinned version, non-https base, design_detector off, and IMPECCABLE_HOME inside the repo; the offer and the silenced hint; pin completeness per platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(design): ask once before downloading impeccable's engine When the probe prints DESIGN_DETECTOR_INSTALL_OFFER the design skills ask the user one AskUserQuestion, in interactive sessions only (spawned or headless runs never install and never ask; Conductor gets the prose brief), before any other step: install the engine now, not now, never ask again (design_detector_install_prompted), or turn the detector off. A yes runs the receipted, checksum-pinned install and the skill continues with a READY probe. The brief says what impeccable is, what the one file is, where it goes, how it is verified and logged, and that no skill or hook comes with it; users who want the /impeccable skill run npx impeccable install themselves. design-review carries the brief inline (it is not carved). design-html keeps its skeleton small: the probe block points at a new read-on-demand section, sections/detector-install-offer.md, registered in its manifest and carve guard; its skeleton ceiling is re-measured (55,262) and its eager ceiling set to the measured 13,767. The review and ship passes state that they never offer an install. NOTICE.md, README, docs/skills.md, the interop design doc, and the CHANGELOG describe the new posture: gstack still never runs impeccable's installer or launcher; the one download is consented, pinned, and receipted. Ship goldens refreshed for the review-pass wording. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1291 lines
59 KiB
TypeScript
1291 lines
59 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Generate SKILL.md files from .tmpl templates.
|
|
*
|
|
* Pipeline:
|
|
* read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
|
|
*
|
|
* Supports --dry-run: generate to memory, exit 1 if different from committed file.
|
|
* Used by skill:check and CI freshness checks.
|
|
*/
|
|
|
|
import { discoverTemplates, discoverSectionTemplates } from './discover-skills';
|
|
import { writeLlmsTxt } from './gen-llms-txt';
|
|
import { generateDesignChecklistMd } from './resolvers/design-checklist';
|
|
import { DOM_DUMP_SCRIPT, DOM_DUMP_FILE } from '../lib/dom-dump-script';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type { Host, TemplateContext } from './resolvers/types';
|
|
import { HOST_PATHS } from './resolvers/types';
|
|
import { RESOLVERS } from './resolvers/index';
|
|
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
|
|
import type { HostConfig } from './host-config';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
|
|
// ─── GBrain Detection Override ──────────────────────────────
|
|
// When --respect-detection is passed, read ~/.gstack/gbrain-detection.json
|
|
// and un-suppress GBRAIN_CONTEXT_LOAD + GBRAIN_SAVE_RESULTS for hosts that
|
|
// statically suppress them (claude, codex, slate, factory, opencode,
|
|
// openclaw, cursor, kiro). Detection state is produced by
|
|
// bin/gstack-gbrain-detect and persisted by `gstack-config gbrain-refresh`
|
|
// or by ./setup.
|
|
//
|
|
// Default (no flag): static suppressedResolvers honored as-is. Used by
|
|
// `bun run gen:skill-docs` (CI + canonical checked-in SKILL.md files) so
|
|
// the committed output is reproducible regardless of any developer's
|
|
// local gbrain installation state. Use `bun run gen:skill-docs:user`
|
|
// (which adds --respect-detection) for user-local installs.
|
|
const RESPECT_DETECTION = process.argv.includes('--respect-detection');
|
|
|
|
function loadGbrainOverride(): { detected: boolean } {
|
|
if (!RESPECT_DETECTION) return { detected: false };
|
|
const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack');
|
|
const detectionPath = path.join(stateDir, 'gbrain-detection.json');
|
|
try {
|
|
const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
|
|
// "timeout" = slow-but-healthy engine (#1964); "thin-client" = remote-HTTP
|
|
// MCP brain with no local engine by design (#2051); "engine-locked" = same
|
|
// class (#2456): PGLite is single-writer, so a live `gbrain serve` (e.g.
|
|
// an MCP server) owns the embedded DB — gbrain is installed and healthy,
|
|
// a legitimate holder has the lock, so a transient lock must not silently
|
|
// strip brain blocks from every SKILL.md. All usable — same treatment as
|
|
// "ok", matching gstack-gbrain-detect --is-ok.
|
|
const USABLE = ['ok', 'timeout', 'thin-client', 'engine-locked'];
|
|
return { detected: USABLE.includes(json.gbrain_local_status ?? '') };
|
|
} catch {
|
|
return { detected: false };
|
|
}
|
|
}
|
|
|
|
const GBRAIN_OVERRIDE = loadGbrainOverride();
|
|
|
|
/**
|
|
* Compute effective suppressedResolvers for a host, applying the gbrain
|
|
* detection override when enabled. When the override fires, GBRAIN_*
|
|
* resolvers are removed from the suppression set so they render in the
|
|
* generated SKILL.md.
|
|
*/
|
|
function effectiveSuppressedResolvers(hostConfig: HostConfig): Set<string> {
|
|
let list = hostConfig.suppressedResolvers || [];
|
|
if (GBRAIN_OVERRIDE.detected) {
|
|
list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS');
|
|
}
|
|
return new Set(list);
|
|
}
|
|
|
|
// ─── Host Detection (config-driven) ─────────────────────────
|
|
|
|
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
|
|
type HostArg = Host | 'all';
|
|
const HOST_ARG_VAL: HostArg = (() => {
|
|
if (!HOST_ARG) return 'claude';
|
|
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
|
|
if (val === 'all') return 'all';
|
|
try {
|
|
return resolveHostArg(val) as Host;
|
|
} catch {
|
|
throw new Error(`Unknown host: ${val}. Use ${ALL_HOST_NAMES.join(', ')}, or all.`);
|
|
}
|
|
})();
|
|
|
|
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
|
|
let HOST: Host = HOST_ARG_VAL === 'all' ? 'claude' : HOST_ARG_VAL;
|
|
|
|
// ─── Model Overlay Selection ────────────────────────────────
|
|
// --model is explicit. Without it, each host uses HostConfig.defaultModel.
|
|
// Host defaults are generation fallbacks, not claims that host === model.
|
|
// Missing overlay file → empty string (graceful).
|
|
import { ALL_MODEL_NAMES, resolveModel, type Model } from './models';
|
|
const MODEL_ARG = process.argv.find(a => a.startsWith('--model'));
|
|
const MODEL_ARG_VAL: Model | null = (() => {
|
|
if (!MODEL_ARG) return null;
|
|
const val = MODEL_ARG.includes('=') ? MODEL_ARG.split('=')[1] : process.argv[process.argv.indexOf(MODEL_ARG) + 1];
|
|
const resolved = resolveModel(val);
|
|
if (!resolved) {
|
|
throw new Error(`Unknown model: ${val}. Use ${ALL_MODEL_NAMES.join(', ')}, or a family variant (e.g., claude-opus-4-7, gpt-5.4-mini, o3).`);
|
|
}
|
|
return resolved;
|
|
})();
|
|
|
|
function generationModelForHost(host: Host): Model {
|
|
return MODEL_ARG_VAL ?? getHostConfig(host).defaultModel;
|
|
}
|
|
|
|
// ─── Catalog Mode (v1.45.0.0 T4) ────────────────────────────
|
|
// 'trim' (default): shorten frontmatter description to lead sentence and
|
|
// move routing/voice prose into a "## When to invoke" body section.
|
|
// 'full': legacy v1.44 behavior — full description stays in frontmatter.
|
|
const CATALOG_MODE_ARG = process.argv.find(a => a.startsWith('--catalog-mode'));
|
|
const CATALOG_MODE: 'trim' | 'full' = (() => {
|
|
if (!CATALOG_MODE_ARG) return 'trim';
|
|
const val = CATALOG_MODE_ARG.includes('=')
|
|
? CATALOG_MODE_ARG.split('=')[1]
|
|
: process.argv[process.argv.indexOf(CATALOG_MODE_ARG) + 1];
|
|
if (val !== 'trim' && val !== 'full') {
|
|
throw new Error(`Unknown catalog mode: ${val}. Use 'trim' (default) or 'full'.`);
|
|
}
|
|
return val;
|
|
})();
|
|
|
|
// ─── Explain-level Overlay ──────────────────────────────────
|
|
// --explain-level=terse compresses preamble prose (writing-style, completeness,
|
|
// confusion-protocol, context-health) to a single pointer line at gen time.
|
|
// Default keeps the runtime-conditional behavior (sections render unconditionally,
|
|
// the model skips them when EXPLAIN_LEVEL: terse appears in the preamble echo).
|
|
// Opt-in via the build flag so most users get the runtime-flexible default.
|
|
const EXPLAIN_LEVEL_ARG = process.argv.find(a => a.startsWith('--explain-level'));
|
|
const EXPLAIN_LEVEL: 'default' | 'terse' = (() => {
|
|
if (!EXPLAIN_LEVEL_ARG) return 'default';
|
|
const val = EXPLAIN_LEVEL_ARG.includes('=')
|
|
? EXPLAIN_LEVEL_ARG.split('=')[1]
|
|
: process.argv[process.argv.indexOf(EXPLAIN_LEVEL_ARG) + 1];
|
|
if (val !== 'default' && val !== 'terse') {
|
|
throw new Error(`Unknown explain level: ${val}. Use 'default' or 'terse'.`);
|
|
}
|
|
return val;
|
|
})();
|
|
|
|
// ─── Out-dir (dev workspace render isolation) ───────────────
|
|
// --out-dir <abs-dir> redirects ALL generated output (Claude SKILL.md +
|
|
// sections, external-host trees like .agents/.factory, openclaw docs,
|
|
// gstack/llms.txt) into a separate (untracked) directory instead of writing
|
|
// in place. OUTPUTS ONLY: inputs (templates, sections/, host configs) are
|
|
// always read from ROOT. For the Claude host it ALSO rewrites the literal
|
|
// section-base path (`~/.claude/skills/gstack/<skill>/sections/`) inside
|
|
// generated content so section Reads resolve to the rendered copy — that
|
|
// rewrite stays Claude-only (external hosts have their own path grammar).
|
|
// Consumers: bin/dev-setup (renders the gbrain `:user` variant for a
|
|
// Conductor workspace — byte-compat pinned by gen-skill-docs-out-dir tests)
|
|
// and the former TREE_MUTATING tests, which render into a mkdtemp instead
|
|
// of mutating the live tree. Default (unset) = in-place, unchanged.
|
|
/** Parse `--flag <path>` / `--flag=<path>` into an absolute path, or null when absent. */
|
|
function parsePathFlag(flag: string): string | null {
|
|
const arg = process.argv.find(a => a.startsWith(flag));
|
|
if (!arg) return null;
|
|
const val = arg.includes('=')
|
|
? arg.split('=')[1]
|
|
: process.argv[process.argv.indexOf(arg) + 1];
|
|
if (!val) throw new Error(`${flag} requires a directory path`);
|
|
return path.resolve(val);
|
|
}
|
|
const OUT_DIR: string | null = parsePathFlag('--out-dir');
|
|
|
|
// External-host outputs rendered in THIS run, keyed by host. Used after the
|
|
// render to prune `gstack-*` output dirs whose skill no longer exists: the
|
|
// generator never deleted, so a retired skill stayed rendered (and linked by
|
|
// setup) forever, still reading config keys the DEFAULTS table had dropped.
|
|
const RENDERED_EXTERNAL: Map<string, Set<string>> = new Map();
|
|
|
|
// #2692: callers that render into a TMP dir and atomically swap it into place
|
|
// (bin/gstack-config gbrain-refresh, setup — the #2569 pattern) must pass the
|
|
// FINAL directory here, or rewriteSectionBase bakes the tmp path
|
|
// (…/render/claude.tmp.<pid>/…) into the rendered CONTENT and every section
|
|
// Read dies after the swap. Defaults to OUT_DIR for direct-render callers
|
|
// (bin/dev-setup, scripts/dev-skill.ts, mkdtemp tests), where out-dir IS the
|
|
// serving path.
|
|
const LINK_ROOT: string | null = parsePathFlag('--link-root') ?? OUT_DIR;
|
|
|
|
/**
|
|
* When rendering to an out-dir, repoint the literal section-base path at the
|
|
* link root (--link-root, defaulting to --out-dir) so section Reads resolve
|
|
* to the SERVED copy, not the global install.
|
|
* Surgical: ONLY paths containing `/sections/` are rewritten — bin/, browse/,
|
|
* docs/ references keep pointing at `~/.claude/skills/gstack` (the global
|
|
* install, which still works). No-op when neither flag is set.
|
|
*/
|
|
function rewriteSectionBase(content: string): string {
|
|
if (!LINK_ROOT) return content;
|
|
// Replacement CALLBACK, not a template string: `$` sequences in a
|
|
// configured path are special in JS replacement strings ($&, $', $1…).
|
|
return content.replace(
|
|
/~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
|
|
(_m, p1: string) => `${LINK_ROOT}/${p1}`,
|
|
);
|
|
}
|
|
|
|
// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
|
|
// Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS)
|
|
// live in ./resolvers/constants and are consumed by resolvers directly.
|
|
|
|
// ─── External Host Helpers ───────────────────────────────────
|
|
|
|
// Canonical implementation (the codex-helpers.ts shadow copy was deleted —
|
|
// it was imported, immediately shadowed by this declaration, and stale)
|
|
// Accepts optional frontmatter name to support directory/invocation name divergence
|
|
function externalSkillName(skillDir: string, frontmatterName?: string): string {
|
|
// Root skill (skillDir === '' or '.') always maps to 'gstack' regardless of frontmatter
|
|
if (skillDir === '.' || skillDir === '') return 'gstack';
|
|
// Use frontmatter name when it differs from directory name (e.g., run-tests/ with name: test)
|
|
const baseName = frontmatterName && frontmatterName !== skillDir ? frontmatterName : skillDir;
|
|
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
|
|
if (baseName.startsWith('gstack-')) return baseName;
|
|
return `gstack-${baseName}`;
|
|
}
|
|
|
|
function extractNameAndDescription(content: string): { name: string; description: string } {
|
|
const fmStart = content.indexOf('---\n');
|
|
if (fmStart !== 0) return { name: '', description: '' };
|
|
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
|
if (fmEnd === -1) return { name: '', description: '' };
|
|
|
|
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
|
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
const name = nameMatch ? nameMatch[1].trim() : '';
|
|
|
|
let description = '';
|
|
const lines = frontmatter.split('\n');
|
|
let inDescription = false;
|
|
const descLines: string[] = [];
|
|
for (const line of lines) {
|
|
if (line.match(/^description:\s*\|?\s*$/)) {
|
|
inDescription = true;
|
|
continue;
|
|
}
|
|
if (line.match(/^description:\s*\S/)) {
|
|
description = line.replace(/^description:\s*/, '').trim();
|
|
break;
|
|
}
|
|
if (inDescription) {
|
|
if (line === '' || line.match(/^\s/)) {
|
|
descLines.push(line.replace(/^ /, ''));
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (descLines.length > 0) {
|
|
description = descLines.join('\n').trim();
|
|
}
|
|
|
|
return { name, description };
|
|
}
|
|
|
|
// ─── Voice Trigger Processing ────────────────────────────────
|
|
|
|
/**
|
|
* Extract voice-triggers YAML list from frontmatter.
|
|
* Returns an array of trigger strings, or [] if no voice-triggers field.
|
|
*/
|
|
function extractVoiceTriggers(content: string): string[] {
|
|
const fmStart = content.indexOf('---\n');
|
|
if (fmStart !== 0) return [];
|
|
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
|
if (fmEnd === -1) return [];
|
|
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
|
|
|
const triggers: string[] = [];
|
|
let inVoice = false;
|
|
for (const line of frontmatter.split('\n')) {
|
|
if (/^voice-triggers:/.test(line)) { inVoice = true; continue; }
|
|
if (inVoice) {
|
|
const m = line.match(/^\s+-\s+"(.+)"$/);
|
|
if (m) triggers.push(m[1]);
|
|
else if (!/^\s/.test(line)) break;
|
|
}
|
|
}
|
|
return triggers;
|
|
}
|
|
|
|
/**
|
|
* Preprocess voice triggers: fold voice-triggers YAML field into description,
|
|
* then strip the field from frontmatter. Must run BEFORE transformFrontmatter
|
|
* and extractNameAndDescription so all hosts see the updated description.
|
|
*/
|
|
function processVoiceTriggers(content: string): string {
|
|
const triggers = extractVoiceTriggers(content);
|
|
if (triggers.length === 0) return content;
|
|
|
|
// Strip voice-triggers block from frontmatter
|
|
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
|
|
|
// Get current description (after stripping voice-triggers, so it's clean)
|
|
const { description } = extractNameAndDescription(content);
|
|
if (!description) return content;
|
|
|
|
// Build new description with voice triggers appended
|
|
const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`;
|
|
const newDescription = description + '\n' + voiceLine;
|
|
|
|
// Replace old indented description with new in frontmatter
|
|
const oldIndented = description.split('\n').map(l => ` ${l}`).join('\n');
|
|
const newIndented = newDescription.split('\n').map(l => ` ${l}`).join('\n');
|
|
content = content.replace(oldIndented, newIndented);
|
|
|
|
return content;
|
|
}
|
|
|
|
// Export for testing
|
|
export { extractVoiceTriggers, processVoiceTriggers };
|
|
|
|
// ─── Catalog Trim (v1.45.0.0 T4) ─────────────────────────────
|
|
//
|
|
// Frontmatter `description:` blocks today pack: a one-line outcome, "Use when
|
|
// asked to..." voice triggers, "Proactively..." routing guidance, and a
|
|
// "(gstack)" tag. This pile is the always-loaded catalog surface — every
|
|
// session pays for the full text. The catalog trim splits the description
|
|
// into a one-line catalog entry (lead sentence + "(gstack)") that stays in
|
|
// the frontmatter, and a "## When to invoke" body section that holds the
|
|
// routing/voice triggers prose for in-skill discovery.
|
|
//
|
|
// Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full
|
|
// description in frontmatter). Use when debugging routing regressions or
|
|
// when shipping skills to hosts that depend on the legacy fat catalog.
|
|
|
|
export interface CatalogParts {
|
|
lead: string; // First sentence — kept in catalog
|
|
routingProse: string; // "Use when asked to...", "Proactively..." paragraphs
|
|
voiceLine: string | null; // "Voice triggers (speech-to-text aliases): ..." line if present
|
|
hasGstackTag: boolean;
|
|
}
|
|
|
|
export function splitCatalogDescription(description: string): CatalogParts {
|
|
// Voice triggers line (folded in by processVoiceTriggers earlier)
|
|
const voiceMatch = description.match(/Voice triggers \(speech-to-text aliases\):[^\n]+/);
|
|
const voiceLine = voiceMatch ? voiceMatch[0] : null;
|
|
let working = voiceLine ? description.replace(voiceLine, '').trim() : description.trim();
|
|
|
|
const hasGstackTag = /\(gstack\)/.test(working);
|
|
if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim();
|
|
|
|
// Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by
|
|
// whitespace or end-of-text. Terminator chars NOT followed by whitespace/end
|
|
// (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the
|
|
// second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two
|
|
// alternatives are disjoint character classes, so there is no ambiguity and
|
|
// no catastrophic-backtracking risk. If no terminator-followed-by-boundary
|
|
// exists at all, we fall back to a 20-word cut below.
|
|
// First normalize to single-line for sentence detection, then back out.
|
|
const collapsed = working.replace(/\s+/g, ' ').trim();
|
|
const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/);
|
|
// sentenceLead is the FULL first sentence (no truncation). We compute routing
|
|
// from this position, then optionally truncate the displayed lead afterwards.
|
|
// Truncating first then computing routing was the v1.45.0.0 bug — when the
|
|
// first sentence exceeded 200 chars, the routing extraction would lose the
|
|
// entire tail of the description (design-consultation's "Use when..."
|
|
// routing prose silently dropped).
|
|
const sentenceLead = sentenceMatch ? sentenceMatch[1].trim() : collapsed.split(/\s/).slice(0, 20).join(' ');
|
|
|
|
// Routing prose: everything AFTER the first sentence boundary in the collapsed view.
|
|
const leadInCollapsed = collapsed.indexOf(sentenceLead);
|
|
const routingCollapsed = leadInCollapsed >= 0
|
|
? collapsed.slice(leadInCollapsed + sentenceLead.length).trim()
|
|
: '';
|
|
|
|
// Now produce the displayed lead — truncated if too long. The original
|
|
// sentenceLead is preserved for routing extraction below.
|
|
let lead = sentenceLead;
|
|
if (lead.length > 200) {
|
|
const trunc = lead.slice(0, 197);
|
|
const lastSpace = trunc.lastIndexOf(' ');
|
|
lead = (lastSpace > 60 ? trunc.slice(0, lastSpace) : trunc) + '...';
|
|
}
|
|
// Restore line breaks for routing prose by mapping back to original layout.
|
|
// Use original whitespace structure where possible; fall back to collapsed.
|
|
// Anchor recovery on sentenceLead (the untruncated first sentence) — not
|
|
// `lead` (which may have a "..." suffix and won't substring-match `working`).
|
|
let routingProse = routingCollapsed;
|
|
const collapsedLeadIdx = working.replace(/\s+/g, ' ').indexOf(sentenceLead);
|
|
if (collapsedLeadIdx >= 0) {
|
|
let consumed = 0;
|
|
let cut = 0;
|
|
for (let i = 0; i < working.length && consumed < collapsedLeadIdx + sentenceLead.length; i++) {
|
|
if (/\s/.test(working[i])) {
|
|
if (i === 0 || /\s/.test(working[i - 1])) continue;
|
|
consumed += 1;
|
|
} else {
|
|
consumed += 1;
|
|
}
|
|
cut = i + 1;
|
|
}
|
|
const tail = working.slice(cut).trim();
|
|
if (tail.length > 0) routingProse = tail;
|
|
}
|
|
|
|
return { lead, routingProse, voiceLine, hasGstackTag };
|
|
}
|
|
|
|
/** Build the catalog-trimmed `description:` block. */
|
|
export function buildTrimmedDescription(parts: CatalogParts): string {
|
|
const lead = parts.lead.trim();
|
|
const suffix = parts.hasGstackTag ? ' (gstack)' : '';
|
|
return `${lead}${suffix}`;
|
|
}
|
|
|
|
/** Build the body section that holds the routing/voice prose. */
|
|
export function buildWhenToInvokeSection(parts: CatalogParts): string {
|
|
const lines: string[] = ['## When to invoke this skill', ''];
|
|
if (parts.routingProse) {
|
|
lines.push(parts.routingProse);
|
|
lines.push('');
|
|
}
|
|
if (parts.voiceLine) {
|
|
lines.push(parts.voiceLine);
|
|
lines.push('');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Render a string as a YAML inline scalar value (the text after `key: `),
|
|
* quoting only when a plain scalar would be invalid or ambiguous.
|
|
*
|
|
* The bug this guards (#1778): a description like "Ship workflow: detect..."
|
|
* emitted as a plain scalar has an interior ": " that a strict YAML parser
|
|
* (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
|
|
* "mapping values are not allowed in this context". When quoting is needed we
|
|
* fall back to JSON.stringify, which produces a double-quoted scalar that YAML
|
|
* accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
|
|
* are already valid plain scalars pass through unchanged to keep regen diffs small.
|
|
*/
|
|
export function toYamlInlineScalar(s: string): string {
|
|
const needsQuote =
|
|
s.length === 0 ||
|
|
s !== s.trim() || // leading/trailing whitespace
|
|
/:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
|
|
/\s#/.test(s) || // " #" → inline comment
|
|
/\.\.\./.test(s) || // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it)
|
|
/^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
|
|
return needsQuote ? JSON.stringify(s) : s;
|
|
}
|
|
|
|
/**
|
|
* Apply catalog trim to a SKILL.md body:
|
|
* - shorten frontmatter `description:` to lead + (gstack)
|
|
* - insert "## When to invoke" body section AFTER the generated header
|
|
* (so it lands near the top of body content, where routing guidance
|
|
* belongs)
|
|
*
|
|
* Returns the rewritten content plus the extracted parts.
|
|
*/
|
|
export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null {
|
|
// Locate description block in frontmatter
|
|
if (!content.startsWith('---\n')) return null;
|
|
const fmEnd = content.indexOf('\n---', 4);
|
|
if (fmEnd === -1) return null;
|
|
const frontmatter = content.slice(4, fmEnd);
|
|
|
|
// Match `description: |` block + indented body lines
|
|
const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m)
|
|
|| frontmatter.match(/^description:\s+(.+)$/m);
|
|
if (!descMatch) return null;
|
|
|
|
// Extract full description text
|
|
let descText: string;
|
|
if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) {
|
|
descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim();
|
|
} else {
|
|
descText = descMatch[1].trim();
|
|
}
|
|
|
|
// Skip skills with very short descriptions (already trimmed or no routing prose).
|
|
// Below ~120 chars, splitting adds no value.
|
|
if (descText.length < 120) return null;
|
|
|
|
const parts = splitCatalogDescription(descText);
|
|
// If lead + (gstack) is already most of the text, no trim needed.
|
|
const trimmedLen = buildTrimmedDescription(parts).length;
|
|
if (trimmedLen >= descText.length - 20) return null;
|
|
|
|
// Replace description in frontmatter — keep trailing newline so the next
|
|
// YAML field doesn't collide on the same line as the description value.
|
|
// Quote the value when it would be an invalid YAML plain scalar (the common
|
|
// case: an interior ": " like "Ship workflow: detect..." which a strict YAML
|
|
// parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
|
|
// only quotes when needed, so descriptions without special chars stay plain.
|
|
const newDesc = buildTrimmedDescription(parts);
|
|
// Function replacer (not a string) so a `$` in the description — e.g. a future
|
|
// skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
|
|
// pattern and silently corrupt the frontmatter.
|
|
const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
|
|
const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
|
|
let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
|
|
|
|
// Insert body section after frontmatter (after the closing ---\n and any
|
|
// existing GENERATED header). We insert before the first non-comment line.
|
|
const bodyStart = newContent.indexOf('\n---\n') + 5;
|
|
const whenToInvoke = '\n' + buildWhenToInvokeSection(parts).trim() + '\n';
|
|
// Skip past the generated header if present (it lives after frontmatter close)
|
|
const headerMatch = newContent.slice(bodyStart).match(/^(<!--[^>]*-->\s*\n)+/);
|
|
const insertAt = bodyStart + (headerMatch ? headerMatch[0].length : 0);
|
|
newContent = newContent.slice(0, insertAt) + whenToInvoke + '\n' + newContent.slice(insertAt);
|
|
|
|
return { content: newContent, parts };
|
|
}
|
|
|
|
const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;
|
|
|
|
function condenseOpenAIShortDescription(description: string): string {
|
|
const firstParagraph = description.split(/\n\s*\n/)[0] || description;
|
|
const collapsed = firstParagraph.replace(/\s+/g, ' ').trim();
|
|
if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed;
|
|
|
|
const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3);
|
|
const lastSpace = truncated.lastIndexOf(' ');
|
|
const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated;
|
|
return `${safe}...`;
|
|
}
|
|
|
|
function generateOpenAIYaml(displayName: string, shortDescription: string): string {
|
|
return `interface:
|
|
display_name: ${JSON.stringify(displayName)}
|
|
short_description: ${JSON.stringify(shortDescription)}
|
|
default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
|
|
policy:
|
|
allow_implicit_invocation: true
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Transform frontmatter for external hosts.
|
|
* Claude: strips `sensitive:` field (only Factory uses it).
|
|
* Codex: keeps name + description only, enforces 1024-char limit.
|
|
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
|
|
*/
|
|
function transformFrontmatter(content: string, host: Host): string {
|
|
const hostConfig = getHostConfig(host);
|
|
const fm = hostConfig.frontmatter;
|
|
|
|
if (fm.mode === 'denylist') {
|
|
// Denylist mode: strip listed fields, keep everything else
|
|
for (const field of fm.stripFields || []) {
|
|
if (field === 'voice-triggers') {
|
|
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
|
} else {
|
|
content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
|
|
}
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// Allowlist mode: reconstruct frontmatter with only allowed fields
|
|
const fmStart = content.indexOf('---\n');
|
|
if (fmStart !== 0) return content;
|
|
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
|
if (fmEnd === -1) return content;
|
|
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
|
const body = content.slice(fmEnd + 4);
|
|
const { name, description } = extractNameAndDescription(content);
|
|
|
|
// Description limit enforcement
|
|
if (fm.descriptionLimit) {
|
|
const behavior = fm.descriptionLimitBehavior || 'error';
|
|
if (description.length > fm.descriptionLimit) {
|
|
if (behavior === 'error') {
|
|
throw new Error(
|
|
`${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
|
|
`Compress the description in the .tmpl file.`
|
|
);
|
|
} else if (behavior === 'warn') {
|
|
console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
|
|
}
|
|
// 'truncate' — silently proceed
|
|
}
|
|
}
|
|
|
|
// Build frontmatter with allowed fields
|
|
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
|
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
|
|
|
|
// Add extra fields (host-wide)
|
|
if (fm.extraFields) {
|
|
for (const [key, value] of Object.entries(fm.extraFields)) {
|
|
if (key !== 'name' && key !== 'description') {
|
|
newFm += `${key}: ${value}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add conditional fields
|
|
if (fm.conditionalFields) {
|
|
for (const rule of fm.conditionalFields) {
|
|
const match = Object.entries(rule.if).every(([k, v]) =>
|
|
new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
|
|
);
|
|
if (match) {
|
|
for (const [key, value] of Object.entries(rule.add)) {
|
|
newFm += `${key}: ${value}\n`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preserve additional keepFields beyond name and description
|
|
if (fm.keepFields) {
|
|
for (const field of fm.keepFields) {
|
|
if (field === 'name' || field === 'description') continue;
|
|
// Match YAML field with possible multi-line/array value (indented lines after colon)
|
|
const fieldMatch = frontmatter.match(new RegExp(`^${field}:(.*(?:\\n(?:[ \\t]+.+))*)`, 'm'));
|
|
if (fieldMatch) {
|
|
newFm += `${field}:${fieldMatch[1]}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rename fields (copy values from template frontmatter with new keys)
|
|
if (fm.renameFields) {
|
|
for (const [oldName, newName] of Object.entries(fm.renameFields)) {
|
|
const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
|
|
if (fieldMatch) {
|
|
newFm += `${newName}:${fieldMatch[1]}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
newFm += '---';
|
|
return newFm + body;
|
|
}
|
|
|
|
/**
|
|
* Extract hook descriptions from frontmatter for inline safety prose.
|
|
* Returns a description of what the hooks do, or null if no hooks.
|
|
*/
|
|
function extractHookSafetyProse(tmplContent: string): string | null {
|
|
if (!tmplContent.match(/^hooks:/m)) return null;
|
|
|
|
// Parse the hook matchers to build a human-readable safety description
|
|
const matchers: string[] = [];
|
|
const matcherRegex = /matcher:\s*"(\w+)"/g;
|
|
let m;
|
|
while ((m = matcherRegex.exec(tmplContent)) !== null) {
|
|
if (!matchers.includes(m[1])) matchers.push(m[1]);
|
|
}
|
|
|
|
if (matchers.length === 0) return null;
|
|
|
|
// Build safety prose based on what tools are hooked
|
|
const toolDescriptions: Record<string, string> = {
|
|
Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution',
|
|
Edit: 'verify file edits are within the allowed scope boundary before applying',
|
|
Write: 'verify file writes are within the allowed scope boundary before applying',
|
|
};
|
|
|
|
const safetyChecks = matchers
|
|
.map(t => toolDescriptions[t] || `check ${t} operations for safety`)
|
|
.join(', and ');
|
|
|
|
return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
|
|
}
|
|
|
|
// ─── External Host Config (now derived from hosts/*.ts) ──────
|
|
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
|
|
|
|
// ─── Template Processing ────────────────────────────────────
|
|
|
|
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
|
|
|
|
/**
|
|
* Apply a host's configured path + tool rewrites. Extracted so both SKILL.md
|
|
* (via processExternalHost) and section files (via processSectionTemplate) get
|
|
* identical per-host treatment — a section's cross-references must rewrite the
|
|
* same way the parent skill's do, or external hosts get wrong paths.
|
|
*/
|
|
function applyHostRewrites(content: string, hostConfig: HostConfig): string {
|
|
let result = content;
|
|
for (const rewrite of hostConfig.pathRewrites) {
|
|
result = result.replaceAll(rewrite.from, rewrite.to);
|
|
}
|
|
if (hostConfig.toolRewrites) {
|
|
for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
|
|
result = result.replaceAll(from, to);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry,
|
|
* honoring host suppression and appliesTo gating, then assert nothing is left
|
|
* unresolved. Extracted so SKILL.md and section templates resolve through the
|
|
* exact same path — a security/sanitization fix to one can't miss the other.
|
|
*/
|
|
/**
|
|
* A second {{PREAMBLE}} in one template re-expands the entire ~12K-token
|
|
* preamble mid-document (#2508/#2362 — a PROSE mention of the macro in
|
|
* spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load).
|
|
* Resolution is context-blind, so any second occurrence — code fence, prose,
|
|
* anywhere — is a generation error, never intentional. Throw at render time
|
|
* so the mistake cannot reach a generated SKILL.md again.
|
|
*/
|
|
export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void {
|
|
const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length;
|
|
if (count > 1) {
|
|
throw new Error(
|
|
`${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it `
|
|
+ `at most once (each occurrence expands the full preamble; see #2508/#2362). `
|
|
+ `Refer to "the preamble" in prose instead of the macro.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolvePlaceholders(
|
|
tmplContent: string,
|
|
ctx: TemplateContext,
|
|
hostConfig: HostConfig,
|
|
relTmplPath: string,
|
|
): string {
|
|
assertSinglePreamble(tmplContent, relTmplPath);
|
|
// effectiveSuppressedResolvers() honors --respect-detection: when gbrain is
|
|
// detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and
|
|
// section generation so both paths get the same gbrain-aware behavior.
|
|
const suppressed = effectiveSuppressedResolvers(hostConfig);
|
|
const onePass = (input: string): string =>
|
|
input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
|
|
const parts = fullKey.split(':');
|
|
const resolverName = parts[0];
|
|
const args = parts.slice(1);
|
|
if (suppressed.has(resolverName)) return '';
|
|
const resolve = RESOLVERS[resolverName];
|
|
if (!resolve) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
|
|
return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
|
|
});
|
|
|
|
// Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the
|
|
// {{SECTION:id}} resolver inlines a section template (with its own resolvers)
|
|
// for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until
|
|
// the output stabilizes. Bounded to avoid an infinite loop if a resolver ever
|
|
// emits its own placeholder; 6 passes is far more nesting than any skill needs.
|
|
let content = tmplContent;
|
|
for (let pass = 0; pass < 6; pass++) {
|
|
const next = onePass(content);
|
|
if (next === content) break;
|
|
content = next;
|
|
}
|
|
|
|
const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g);
|
|
if (remaining) {
|
|
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Build the TemplateContext from a template's frontmatter. Shared by SKILL.md
|
|
* and section generation so sections inherit the SAME context the parent skill
|
|
* resolves with (skillName, tier, benefitsFrom, interactive) — enforced by
|
|
* test/template-context-parity.test.ts. skillNameOverride lets section
|
|
* generation pin the parent skill's name instead of deriving "sections".
|
|
*/
|
|
function buildContext(
|
|
tmplContent: string,
|
|
tmplPath: string,
|
|
host: Host,
|
|
skillNameOverride?: string,
|
|
): TemplateContext {
|
|
const { name: extractedName } = extractNameAndDescription(tmplContent);
|
|
const skillName = skillNameOverride || extractedName || path.basename(path.dirname(tmplPath));
|
|
const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m);
|
|
const benefitsFrom = benefitsMatch
|
|
? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
|
: undefined;
|
|
const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m);
|
|
const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined;
|
|
const interactiveMatch = tmplContent.match(/^interactive:\s*(true|false)\s*$/m);
|
|
const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined;
|
|
return {
|
|
skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
|
|
preambleTier, model: generationModelForHost(host), interactive, explainLevel: EXPLAIN_LEVEL,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process external host output: routing, frontmatter, path rewrites, metadata.
|
|
* Shared between Codex and Factory (and future external hosts).
|
|
*/
|
|
function processExternalHost(
|
|
content: string,
|
|
tmplContent: string,
|
|
host: Host,
|
|
skillDir: string,
|
|
extractedDescription: string,
|
|
ctx: TemplateContext,
|
|
frontmatterName?: string,
|
|
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
|
|
const hostConfig = getHostConfig(host);
|
|
|
|
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
|
|
// --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
|
|
const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name);
|
|
if (!RENDERED_EXTERNAL.has(host)) RENDERED_EXTERNAL.set(host, new Set());
|
|
RENDERED_EXTERNAL.get(host)!.add(name);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const outputPath = path.join(outputDir, 'SKILL.md');
|
|
|
|
// Guard against symlink loops
|
|
let symlinkLoop = false;
|
|
const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
|
|
try {
|
|
const resolvedClaude = fs.realpathSync(claudePath);
|
|
const resolvedExternal = fs.realpathSync(path.dirname(outputPath)) + '/' + path.basename(outputPath);
|
|
if (resolvedClaude === resolvedExternal) {
|
|
symlinkLoop = true;
|
|
}
|
|
} catch {
|
|
// realpathSync fails if file doesn't exist yet — no symlink loop
|
|
}
|
|
|
|
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
|
|
const safetyProse = extractHookSafetyProse(tmplContent);
|
|
|
|
// Transform frontmatter (host-aware)
|
|
let result = transformFrontmatter(content, host);
|
|
|
|
// Insert safety advisory at the top of the body (after frontmatter)
|
|
if (safetyProse) {
|
|
const bodyStart = result.indexOf('\n---') + 4;
|
|
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
|
|
}
|
|
|
|
// Config-driven path + tool rewrites (shared with processSectionTemplate so
|
|
// section cross-references get the same per-host treatment as SKILL.md).
|
|
result = applyHostRewrites(result, hostConfig);
|
|
|
|
// Config-driven: generate metadata (e.g., openai.yaml for Codex)
|
|
if (hostConfig.generation.generateMetadata && !symlinkLoop) {
|
|
const agentsDir = path.join(outputDir, 'agents');
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
|
|
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(name, shortDescription));
|
|
}
|
|
|
|
return { content: result, outputPath, outputDir, symlinkLoop };
|
|
}
|
|
|
|
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string; symlinkLoop?: boolean } {
|
|
// Normalize to LF at the entry point. Templates may have CRLF on disk when
|
|
// checked out on Windows with core.autocrlf=true. Downstream regexes
|
|
// (processVoiceTriggers, transformFrontmatter) hardcode \n, so without
|
|
// normalization they silently no-op on CRLF — producing different output
|
|
// than CI (Linux, LF) and breaking the Skill Docs Freshness check.
|
|
// (catalogParts left the return type with the proactive-suggestions
|
|
// retirement — merge of the two v1.64 waves.)
|
|
const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n');
|
|
const relTmplPath = path.relative(ROOT, tmplPath);
|
|
let outputPath = tmplPath.replace(/\.tmpl$/, '');
|
|
|
|
// Determine skill directory relative to ROOT
|
|
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
|
|
|
|
// --out-dir: mirror the skill tree into the out-dir instead of writing in
|
|
// place (external hosts compute their own OUT_DIR-aware paths below).
|
|
if (OUT_DIR && host === 'claude') {
|
|
outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
|
|
}
|
|
|
|
// Extract name/description: name drives external skill naming + setup symlinks
|
|
// (and TemplateContext.skillName via buildContext); description feeds external
|
|
// host metadata. When frontmatter name: differs from directory name (e.g.
|
|
// run-tests/ with name: test), the frontmatter name wins.
|
|
const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);
|
|
|
|
const currentHostConfig = getHostConfig(host);
|
|
const ctx = buildContext(tmplContent, tmplPath, host);
|
|
const skillName = ctx.skillName;
|
|
|
|
// Replace placeholders + assert none remain (shared path with section generation).
|
|
let content = resolvePlaceholders(tmplContent, ctx, currentHostConfig, relTmplPath);
|
|
|
|
// Preprocess voice triggers: fold into description, strip field from frontmatter.
|
|
// Must run BEFORE transformFrontmatter so all hosts see the updated description,
|
|
// and BEFORE extractedDescription is used by external host metadata.
|
|
content = processVoiceTriggers(content);
|
|
|
|
// Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml
|
|
// metadata gets the updated description with voice triggers included.
|
|
const postProcessDescription = extractNameAndDescription(content).description;
|
|
|
|
// For Claude: strip sensitive: field (only Factory uses it)
|
|
// For external hosts: route output, transform frontmatter, rewrite paths
|
|
let symlinkLoop = false;
|
|
if (host === 'claude') {
|
|
content = transformFrontmatter(content, host);
|
|
} else {
|
|
const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, extractedName || undefined);
|
|
content = result.content;
|
|
outputPath = result.outputPath;
|
|
symlinkLoop = result.symlinkLoop;
|
|
}
|
|
|
|
// Prepend generated header (after frontmatter)
|
|
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
|
|
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
|
|
if (fmEnd !== -1) {
|
|
const insertAt = content.indexOf('\n', fmEnd) + 1;
|
|
content = content.slice(0, insertAt) + header + content.slice(insertAt);
|
|
} else {
|
|
content = header + content;
|
|
}
|
|
|
|
// Catalog trim (Claude only — external hosts have their own frontmatter shapes)
|
|
if (host === 'claude' && CATALOG_MODE === 'trim') {
|
|
const trimmed = applyCatalogTrim(content, skillName);
|
|
if (trimmed) content = trimmed.content;
|
|
}
|
|
|
|
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
|
|
if (host === 'claude') content = rewriteSectionBase(content);
|
|
|
|
return { outputPath, content, symlinkLoop };
|
|
}
|
|
|
|
/**
|
|
* Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
|
|
* `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
|
|
* no voice triggers. They resolve placeholders through the SAME path as
|
|
* SKILL.md (resolvePlaceholders) using the PARENT skill's TemplateContext
|
|
* (so appliesTo gating + tier behave identically — a section's {{PREAMBLE}}-
|
|
* style resolver renders the same content it would in the parent, not empty).
|
|
*
|
|
* Output routing mirrors SKILL.md: Claude writes in-tree at
|
|
* `<skill>/sections/<name>.md`; external hosts write to
|
|
* `<hostSubdir>/skills/<externalName>/sections/<name>.md`. External hosts get
|
|
* applyHostRewrites so cross-references resolve per host.
|
|
*/
|
|
function processSectionTemplate(
|
|
sectionTmplPath: string,
|
|
skillDir: string,
|
|
host: Host = 'claude',
|
|
): { outputPath: string; content: string } {
|
|
const tmplContent = fs.readFileSync(sectionTmplPath, 'utf-8');
|
|
const relTmplPath = path.relative(ROOT, sectionTmplPath);
|
|
const hostConfig = getHostConfig(host);
|
|
|
|
// Read the owning SKILL.md.tmpl so the section inherits the parent's name +
|
|
// tier + benefits-from (TemplateContext parity). Fall back to the dir name.
|
|
const parentTmplPath = path.join(ROOT, skillDir, 'SKILL.md.tmpl');
|
|
const parentContent = fs.existsSync(parentTmplPath) ? fs.readFileSync(parentTmplPath, 'utf-8') : '';
|
|
const parentName = (parentContent && extractNameAndDescription(parentContent).name) || skillDir;
|
|
const ctx = buildContext(parentContent || tmplContent, parentTmplPath, host, parentName);
|
|
|
|
// Resolve placeholders against the section body (shared guard catches stragglers).
|
|
let content = resolvePlaceholders(tmplContent, ctx, hostConfig, relTmplPath);
|
|
|
|
// External hosts: rewrite cross-reference paths/tools (no frontmatter to transform).
|
|
if (host !== 'claude') {
|
|
content = applyHostRewrites(content, hostConfig);
|
|
} else {
|
|
// --out-dir: a section may cross-reference another section by absolute path;
|
|
// repoint those to the out-dir too (no-op when --out-dir is unset).
|
|
content = rewriteSectionBase(content);
|
|
}
|
|
|
|
// Plain generated header (no frontmatter to insert after).
|
|
content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;
|
|
|
|
const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, '');
|
|
let outputPath: string;
|
|
if (host === 'claude') {
|
|
outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName);
|
|
} else {
|
|
const externalName = externalSkillName(skillDir, parentName);
|
|
outputPath = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
|
|
}
|
|
if (!DRY_RUN) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
return { outputPath, content };
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────
|
|
|
|
function findTemplates(): string[] {
|
|
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
|
|
}
|
|
|
|
const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
|
|
|
|
/**
|
|
* Write one generated file, or under DRY_RUN compare it to what is on disk and
|
|
* print STALE/FRESH. Returns true when the file is stale (dry run) — the caller
|
|
* folds that into its host-level `hasChanges`. Shared by sections and the
|
|
* lib-derived assets; the SKILL.md loop keeps its own copy because it also
|
|
* handles symlink loops and the token budget.
|
|
*/
|
|
function emitGenerated(outputPath: string, content: string): boolean {
|
|
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
|
|
if (DRY_RUN) {
|
|
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
|
|
if (existing !== content) {
|
|
console.log(`STALE: ${relOutput}`);
|
|
return true;
|
|
}
|
|
console.log(`FRESH: ${relOutput}`);
|
|
return false;
|
|
}
|
|
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, content);
|
|
console.log(`GENERATED: ${relOutput}`);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The generator's whole executable body. Import-purity contract: importing
|
|
* this module must NEVER touch the tree — test/gen-skill-docs.test.ts pulls
|
|
* assertSinglePreamble via require(), test/catalog-trim.test.ts imports
|
|
* helpers, and before this guard existed every such import regenerated all
|
|
* 71 SKILL.md in place at module-load time (the root cause of half the
|
|
* TREE_MUTATING serial shard; hazard class #2532). Pinned by
|
|
* test/gen-skill-docs-import-purity.test.ts.
|
|
*
|
|
* Returns the process exit code. Kept synchronous so the module stays
|
|
* require()-able (see the llms.txt IIFE note below).
|
|
*/
|
|
|
|
export function main(): number {
|
|
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
|
|
const failures: { host: string; error: Error }[] = [];
|
|
|
|
for (const currentHost of hostsToRun) {
|
|
HOST = currentHost;
|
|
|
|
try {
|
|
let hasChanges = false;
|
|
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
|
|
|
|
const currentHostConfig = getHostConfig(currentHost);
|
|
for (const tmplPath of findTemplates()) {
|
|
const dir = path.basename(path.dirname(tmplPath));
|
|
|
|
// includeSkills allowlist (union logic: include minus skip)
|
|
if (currentHostConfig.generation.includeSkills?.length) {
|
|
if (!currentHostConfig.generation.includeSkills.includes(dir)) continue;
|
|
}
|
|
// skipSkills denylist (subtracts from includeSkills or full set)
|
|
if (currentHostConfig.generation.skipSkills?.length) {
|
|
if (currentHostConfig.generation.skipSkills.includes(dir)) continue;
|
|
}
|
|
|
|
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
|
|
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
|
|
|
|
if (symlinkLoop) {
|
|
console.log(`SKIPPED (symlink loop): ${relOutput}`);
|
|
} else if (DRY_RUN) {
|
|
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
|
|
if (existing !== content) {
|
|
console.log(`STALE: ${relOutput}`);
|
|
hasChanges = true;
|
|
} else {
|
|
console.log(`FRESH: ${relOutput}`);
|
|
}
|
|
} else {
|
|
// In-place writes land in existing dirs; --out-dir needs the mirrored
|
|
// skill dir created first.
|
|
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, content);
|
|
console.log(`GENERATED: ${relOutput}`);
|
|
}
|
|
|
|
// Track token budget
|
|
const lines = content.split('\n').length;
|
|
const tokens = Math.round(content.length / 4); // ~4 chars per token
|
|
tokenBudget.push({ skill: relOutput, lines, tokens });
|
|
|
|
// Token ceiling check: warn if any generated SKILL.md exceeds ~40K tokens (160KB).
|
|
// The ceiling is a "watch for feature bloat" guardrail, not a hard gate. Modern
|
|
// flagship models have 200K-1M context windows, so 40K (4-20% of window) is fine.
|
|
// Prompt caching further reduces the marginal cost of larger skills. This ceiling
|
|
// exists to catch a runaway preamble or resolver that's grown by 10K+ tokens in
|
|
// a release, not to force compression on carefully-tuned big skills (ship,
|
|
// plan-ceo-review, office-hours all legitimately pack 25-35K tokens of behavior).
|
|
const TOKEN_CEILING_BYTES = 160_000;
|
|
if (content.length > TOKEN_CEILING_BYTES) {
|
|
console.warn(`⚠️ TOKEN CEILING: ${relOutput} is ${content.length} bytes (~${tokens} tokens), exceeds ${TOKEN_CEILING_BYTES} byte ceiling (~40K tokens)`);
|
|
}
|
|
}
|
|
|
|
// ─── Section generation (v2 plan T9, Claude-first carve) ───
|
|
// On-demand sections/*.md for carved skills. Generated for CLAUDE ONLY:
|
|
// every other host inlines section content via the {{SECTION:id}} resolver
|
|
// (keeping the full monolith skill), so they need no section files and we
|
|
// sidestep host-portable section paths until that plumbing lands. No-op for
|
|
// any skill without a sections/ dir. Mirrors the SKILL.md DRY_RUN handling so
|
|
// sections participate in the freshness gate.
|
|
for (const sec of currentHost === 'claude' ? discoverSectionTemplates(ROOT) : []) {
|
|
if (currentHostConfig.generation.includeSkills?.length &&
|
|
!currentHostConfig.generation.includeSkills.includes(sec.skillDir)) continue;
|
|
if (currentHostConfig.generation.skipSkills?.length &&
|
|
currentHostConfig.generation.skipSkills.includes(sec.skillDir)) continue;
|
|
|
|
const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost);
|
|
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
|
|
if (emitGenerated(outputPath, content)) hasChanges = true;
|
|
|
|
tokenBudget.push({
|
|
skill: relOutput,
|
|
lines: content.split('\n').length,
|
|
tokens: Math.round(content.length / 4),
|
|
});
|
|
}
|
|
|
|
// ─── review/design-checklist.md (generated from lib/design-catalog.ts) ───
|
|
// A Claude-side runtime asset: setup links it from review/ and the other
|
|
// hosts copy or inline the Claude render (hosts/opencode.ts), so it is
|
|
// written for the CLAUDE host only. Honors OUT_DIR (outputs-only rule) and
|
|
// takes part in the DRY_RUN freshness gate exactly like sections above.
|
|
if (currentHost === 'claude'
|
|
&& !(currentHostConfig.generation.includeSkills?.length && !currentHostConfig.generation.includeSkills.includes('review'))
|
|
&& !currentHostConfig.generation.skipSkills?.includes('review')) {
|
|
// Two runtime assets derived from lib/ source: the checklist (from the
|
|
// catalog) and the DOM-dump script the browser engines load at runtime
|
|
// (from lib/dom-dump-script.ts, so the prose never carries the script).
|
|
const generatedAssets: Array<[string, string]> = [
|
|
[path.join('review', 'design-checklist.md'), generateDesignChecklistMd()],
|
|
[DOM_DUMP_FILE, DOM_DUMP_SCRIPT + '\n'],
|
|
];
|
|
for (const [rel, content] of generatedAssets) {
|
|
if (emitGenerated(path.join(OUT_DIR ?? ROOT, rel), content)) hasChanges = true;
|
|
}
|
|
}
|
|
|
|
// Generate the OpenClaw orchestrator-injection docs (gstack-lite / gstack-full /
|
|
// gstack-plan CLAUDE.md snippets). Sources live in openclaw/templates/ —
|
|
// plain markdown, no placeholder resolution — and are copied byte-for-byte
|
|
// to openclaw/ at gen time.
|
|
if (currentHost === 'openclaw' && !DRY_RUN) {
|
|
// Inputs from ROOT, outputs into OUT_DIR when set (outputs-only rule).
|
|
const openclawTemplatesDir = path.join(ROOT, 'openclaw', 'templates');
|
|
const openclawOutDir = path.join(OUT_DIR ?? ROOT, 'openclaw');
|
|
if (OUT_DIR) fs.mkdirSync(openclawOutDir, { recursive: true });
|
|
for (const variant of ['lite', 'full', 'plan'] as const) {
|
|
const fileName = `gstack-${variant}-CLAUDE.md`;
|
|
const content = fs.readFileSync(path.join(openclawTemplatesDir, fileName), 'utf-8');
|
|
fs.writeFileSync(path.join(openclawOutDir, fileName), content);
|
|
console.log(`GENERATED: openclaw/${fileName}`);
|
|
}
|
|
}
|
|
|
|
if (DRY_RUN && hasChanges) {
|
|
console.error(`\nGenerated SKILL.md files are stale (${currentHost} host). Run: bun run gen:skill-docs --host ${currentHost}`);
|
|
if (HOST_ARG_VAL !== 'all') return 1;
|
|
failures.push({ host: currentHost, error: new Error('Stale files detected') });
|
|
}
|
|
|
|
// Print token budget summary
|
|
if (!DRY_RUN && tokenBudget.length > 0) {
|
|
tokenBudget.sort((a, b) => b.lines - a.lines);
|
|
const totalLines = tokenBudget.reduce((s, t) => s + t.lines, 0);
|
|
const totalTokens = tokenBudget.reduce((s, t) => s + t.tokens, 0);
|
|
|
|
console.log('');
|
|
console.log(`Token Budget (${currentHost} host)`);
|
|
console.log('═'.repeat(60));
|
|
for (const t of tokenBudget) {
|
|
const hostSubdirs = ALL_HOST_CONFIGS.map(c => c.hostSubdir.replace('.', '\\.')).join('|');
|
|
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(new RegExp(`^\\.(${hostSubdirs})\\/skills\\/`), '');
|
|
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
|
|
}
|
|
console.log('─'.repeat(60));
|
|
console.log(` ${'TOTAL'.padEnd(30)} ${String(totalLines).padStart(5)} lines ~${String(totalTokens).padStart(6)} tokens`);
|
|
console.log('');
|
|
}
|
|
} catch (e) {
|
|
failures.push({ host: currentHost, error: e as Error });
|
|
console.error(`WARNING: ${currentHost} generation failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// --host all: any host failure fails the build. Previously only claude failures
|
|
// exited nonzero, which let a stale or broken external-host output (e.g. a
|
|
// section that failed to generate for Factory) slip through the freshness gate
|
|
// silently. With sections fanned out across every host, "all hosts regenerated
|
|
// in the same commit" is only a real gate if every host failure is fatal here.
|
|
if (failures.length > 0 && HOST_ARG_VAL === 'all') {
|
|
console.error(`\n${failures.length} host(s) failed: ${failures.map(f => f.host).join(', ')}`);
|
|
return 1;
|
|
}
|
|
// Single host dry-run failure already handled above
|
|
|
|
// After all hosts processed, warn if prefix patches may need re-applying
|
|
if (!DRY_RUN) {
|
|
try {
|
|
const configPath = path.join(process.env.HOME || '', '.gstack', 'config.yaml');
|
|
if (fs.existsSync(configPath)) {
|
|
const config = fs.readFileSync(configPath, 'utf-8');
|
|
if (/^skill_prefix:\s*true/m.test(config)) {
|
|
console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).');
|
|
}
|
|
}
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
// Prune stale external-host outputs. A run always renders every skill for the
|
|
// chosen host(s) (there is no per-skill filter), so any `gstack-*` directory
|
|
// left in <host>/skills/ that this run did not write belongs to a skill that
|
|
// no longer exists. Symlinks (the `gstack` sidecar), non-prefixed entries, and
|
|
// gstack-* directories without the generated banner (someone's own skill) are
|
|
// never touched.
|
|
if (!DRY_RUN) {
|
|
// A host whose generation threw has a PARTIAL rendered set: pruning against
|
|
// it would delete every valid render the loop never reached. Skip those.
|
|
const failedHosts = new Set(failures.map((f) => f.host));
|
|
for (const [host, names] of RENDERED_EXTERNAL) {
|
|
if (failedHosts.has(host)) { console.error(` prune skipped for ${host}: generation failed, rendered set is partial`); continue; }
|
|
const skillsRoot = path.join(OUT_DIR ?? ROOT, getHostConfig(host as Host).hostSubdir, 'skills');
|
|
let entries: fs.Dirent[] = [];
|
|
try { entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); } catch { continue; }
|
|
for (const e of entries) {
|
|
if (e.isSymbolicLink() || !e.isDirectory() || !e.name.startsWith('gstack-') || names.has(e.name)) continue;
|
|
// Only a directory we provably rendered (the generated banner in its
|
|
// SKILL.md) may be deleted whole — a hand-authored gstack-* dir is kept.
|
|
let generated = false;
|
|
try { generated = fs.readFileSync(path.join(skillsRoot, e.name, 'SKILL.md'), 'utf-8').includes('<!-- AUTO-GENERATED from'); } catch { generated = false; }
|
|
if (!generated) { console.log(` kept ${host} skills/${e.name}: not a gstack render (no generated banner)`); continue; }
|
|
fs.rmSync(path.join(skillsRoot, e.name), { recursive: true, force: true });
|
|
console.log(` pruned stale ${host} render: ${e.name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regenerate gstack/llms.txt — single-file capability index for AI agents.
|
|
// Runs after SKILL.md generation so it sees current skill descriptions and
|
|
// browse command list. Wrapped in an IIFE so the await-import doesn't make
|
|
// this module async (test/gen-skill-docs.test.ts uses require() to pull
|
|
// extractVoiceTriggers/processVoiceTriggers, which fails on async modules).
|
|
// Freshness is asserted in test/llms-txt-shape.test.ts.
|
|
if (!DRY_RUN) {
|
|
void (async () => {
|
|
try {
|
|
const result = await writeLlmsTxt(
|
|
// Outputs-only rule: under --out-dir even this index lands there
|
|
// (a catalog-mode render must never rewrite the tracked llms.txt).
|
|
OUT_DIR ? { outputPath: path.join(OUT_DIR, 'gstack', 'llms.txt') } : {},
|
|
);
|
|
if (result.warnings.length > 0) {
|
|
for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`);
|
|
} else {
|
|
console.log(`[gen-llms-txt] gstack/llms.txt: ${result.skills.length} skills, ${result.browseCommands.length} browse commands`);
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(`[gen-llms-txt] FAILED: ${msg}`);
|
|
}
|
|
// Regenerate agents-digest/gstack-AGENTS.md — the instruction-only tier
|
|
// for rules-reading hosts with no skill install. Committed artifact;
|
|
// freshness + byte budget asserted in test/agents-digest.test.ts.
|
|
try {
|
|
const { writeAgentsDigest, DIGEST_BYTE_BUDGET } = await import('./gen-agents-digest');
|
|
// Outputs-only rule: under --out-dir the digest lands there too — a
|
|
// workspace render must never rewrite the tracked committed artifact.
|
|
const digest = writeAgentsDigest(OUT_DIR ? { outRoot: OUT_DIR } : {});
|
|
console.log(`[gen-agents-digest] agents-digest/gstack-AGENTS.md: ${digest.bytes} bytes (budget ${DIGEST_BYTE_BUDGET})`);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error(`[gen-agents-digest] FAILED: ${msg}`);
|
|
// The digest is a committed freshness-gated artifact: a local build
|
|
// that silently ships it stale defers the red to CI. Fail the build.
|
|
process.exitCode = 1;
|
|
}
|
|
})();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
// Failure exits are immediate (matching the old top-level process.exit
|
|
// behavior); success leaves the event loop to drain so the llms.txt
|
|
// fire-and-forget IIFE inside main() finishes its write.
|
|
const code = main();
|
|
if (code !== 0) process.exit(code);
|
|
}
|