mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
Merge origin/main (v1.80.0.0) into consolidate-browser-skills-into-aside; queue-advance release to v1.81.0.0
Both sides claimed v1.80.0.0, so VERSION, package.json and the agents digest auto-merged without a conflict; the release is re-versioned to the next free MINOR slot (bin/gstack-next-version), the size-budget baseline renamed to match, and the CHANGELOG carries the Aside-first entry above main's. Two semantic conflicts hidden in the clean setup auto-merge are resolved here: - _prune_stale_generated deleted a REAL host directory on banner-only proof; main's #2119 gate makes that weak proof file-scoped, so real dirs now go through _cleanup_weak_dir (SKILL.md, marker and our links only). - _browser_hint and the Chromium bootstrap summary now consult _PW_FAIL_REASON and Aside presence, so they never promise a bundled browser that cannot launch and never tell an Aside user their browser skills are gone. README, TESTING_INTERNALS and TODOS wording reconciled with the merged tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+49
-3
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.80.0.0] - 2026-09-05
|
||||
## [1.81.0.0] - 2026-09-06
|
||||
|
||||
**Aside is the browser gstack drives first. Every browsing skill, the PDF and diagram renderer, and web research go through it.**
|
||||
**gstack's own headless browser stays as the automatic fallback, so nothing stops working without Aside.**
|
||||
@@ -25,7 +25,7 @@ Source: `git diff origin/main --stat`, `bun test/helpers/capture-context-budget.
|
||||
|
||||
The row you feel is the fallback one: on a Mac with Aside open, QA runs in the browser you already trust; on a Linux box or with Aside closed, the same skill runs the same steps on the bundled browser and tells you so.
|
||||
|
||||
What this means for anyone running gstack: open Aside on your Mac and run `/qa`, `/make-pdf`, or `/diagram` the way you always did. Without Aside, everything behaves as it did in v1.79. Upgrading needs no migration.
|
||||
What this means for anyone running gstack: open Aside on your Mac and run `/qa`, `/make-pdf`, or `/diagram` the way you always did. Without Aside, everything behaves as it did in v1.80. Upgrading needs no migration. Contributed by @time-attack.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
@@ -52,7 +52,53 @@ What this means for anyone running gstack: open Aside on your Mac and run `/qa`,
|
||||
#### For contributors
|
||||
- `test/aside-driver.test.ts` pins the contract's load-bearing sentences and asserts every browsing skill carries `## BROWSER SETUP (Aside` followed by `## Browser fallback`. `test/aside-render.test.ts` pins the renderer's option mapping and script shapes and runs a live render on each engine that is present. `test/helpers/aside-available.ts` is the shared live-Aside probe for E2E gating; `make-pdf/test/e2e/browser-available.ts` gates the make-pdf gates on either engine.
|
||||
- `lib/claude-bin.ts` and `lib/error-handling.ts` are now the canonical copies; `browse/src` re-exports them.
|
||||
- Size budget re-baselined to `parity-baseline-v1.80.0.0.json` (the Aside contract plus fallback block ride in every browsing skill); parity ceilings ratcheted with measured values; touchfiles, tiers, coverage matrix, eval baselines, ship goldens, and the context-budget fixture refreshed. Follow-ups in TODOS.md: keep the `$B` translation table in sync with the cookbook, Aside CLI 1.26 lacks the commands its own skill doc lists, a macOS runner for the live E2E, an optional MCP path for a persistent page.
|
||||
- Size budget re-baselined to `parity-baseline-v1.81.0.0.json` (the Aside contract plus fallback block ride in every browsing skill); parity ceilings ratcheted with measured values; touchfiles, tiers, coverage matrix, eval baselines, ship goldens, and the context-budget fixture refreshed. Follow-ups in TODOS.md: keep the `$B` translation table in sync with the cookbook, Aside CLI 1.26 lacks the commands its own skill doc lists, a macOS runner for the live E2E, an optional MCP path for a persistent page.
|
||||
|
||||
## [1.80.0.0] - 2026-09-04
|
||||
|
||||
**Setup finishes even when Chromium cannot be installed.**
|
||||
**gstack never deletes or overwrites a skill it did not create.**
|
||||
|
||||
Three defects that a downstream fork kept tripping over are fixed at the source. `./setup` used to abort at the Playwright step on any box where the Chromium download failed or hung (offline, proxied, AppArmor-restricted), and because that step ran before skills were registered, those users ended with zero skills. The `/freeze` deny hook read a different state directory than `/freeze` wrote whenever `GSTACK_HOME` was set, so the boundary silently allowed everything. And both `./setup` and `gstack-relink` would replace or delete any skill entry that happened to share a name with a gstack skill, including a skill you wrote yourself.
|
||||
|
||||
Now the Chromium install is best-effort and bounded. It runs under a 600 second deadline you can change with `GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT`, skip outright with `GSTACK_SKIP_PLAYWRIGHT=1`, and every failure becomes a reason code in the final summary that names the skills that need a browser. Skills always register. Both PreToolUse hooks resolve the same state root the writers use, and freeze fails closed on any unexpected death instead of exiting with no decision. Ownership is proven, never assumed from a name: an entry is only touched when it is a symlink into gstack, carries the `.gstack-owned` marker gstack writes for directories it creates, or is a real file gstack generated. Even then a generated file you have since customized is moved to `~/.gstack/backups/skills/<timestamp>/` before gstack links over it, and a directory is only removed whole when nothing of yours is inside.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: the free suite (`bun run test`), specifically `test/setup-playwright-best-effort.test.ts`, `test/setup-link-ownership.test.ts`, `test/relink.test.ts` and `test/hook-scripts.test.ts`, run against this tree.
|
||||
|
||||
| Metric | Before | After | Δ |
|
||||
|---|---|---|---|
|
||||
| Skills registered when the Chromium download fails or hangs | 0 | all | setup never aborts at the bootstrap |
|
||||
| Longest a wedged Chromium install can block `./setup` | unbounded | 600s default | `GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT`, `GSTACK_SKIP_PLAYWRIGHT=1` |
|
||||
| `/freeze` boundary with `GSTACK_HOME` set | allowed every edit | denies | one state-root resolver for hooks and writers |
|
||||
| Sites that could delete or replace a same-name skill without proof of ownership | 5 | 0 | setup linker, alias installer, both flip cleanups, relink |
|
||||
| Data lost when gstack replaces a generated SKILL.md you customized | the file | none | moved to `~/.gstack/backups/skills/<ts>/` |
|
||||
| Free tests pinning these behaviors | 0 | 103 | across 10 files |
|
||||
|
||||
The first row is the one you feel: a laptop on hotel wifi, a CI runner behind a proxy, or a fresh Ubuntu 24.04 box now ends `./setup` with every skill installed and one clear line saying which ones will not work until Chromium is present.
|
||||
|
||||
What this means for anyone installing or upgrading: run `./setup` anywhere and get skills. Keep your own `qa` or `ship` skill next to gstack's and it survives every upgrade and prefix flip, reported by name instead of silently replaced. Set `GSTACK_HOME` and `/freeze` means what it says.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
- **`./setup` no longer aborts when Chromium cannot be installed** (#1900, #1901, #1902, #913). The Playwright step is best-effort and bounded. Reason codes: `skipped`, `chromium-install`, `chromium-install-timeout`, `chromium-install-locked`, `windows-no-node`, `windows-node-modules`, `post-install-launch`. A wedged installer is killed with its whole child tree (pgrep, or a /proc walk where pgrep is missing); Ctrl-C mid-install kills it too. A stale install lock is reclaimed atomically, a garbage pid file counts as stale, and a lock with no recorded holder expires after the install bound. Contributed by @DavidMiserak (#2233).
|
||||
- **`/freeze` enforces its boundary under `GSTACK_HOME`** (#1459). The hook resolves the state root exactly as `bin/gstack-paths` does (GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when CLAUDE_PLUGIN_ROOT names gstack, then `~/.gstack`), a trailing newline in the path round-trips, a helper from an older install denies instead of exiting 127, and an EXIT backstop denies on any unexpected failure. `/careful` reads its project patterns from the same root and falls back safely on a stale helper. Contributed by @NikhileshNanduri (#1509).
|
||||
- **gstack never deletes or links over a skill it does not own** (#2119). `./setup`'s linker and alias installer, both prefix-flip cleanups, and `gstack-relink` all prove ownership first and report a foreign entry by name in the final summary. Runtime assets (sections, templates, checklists) inside a directory gstack did not create are kept, not replaced. A checkout named without a `gstack` path segment (a `git worktree add ../gstack-feature`) still counts as gstack's. Contributed by @smblight.
|
||||
- **Windows copy installs carry a `.gstack-owned` marker** so provenance no longer rests on the directory name. The marker is written on every platform, only for directories gstack creates.
|
||||
- **Setup's one-shot telemetry events no longer finalize other sessions' in-flight markers** as `outcome: unknown`.
|
||||
|
||||
#### Added
|
||||
- `GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>` and `GSTACK_SKIP_PLAYWRIGHT=1` for `./setup`; documented in the README troubleshooting list together with `GSTACK_CHROMIUM_NO_SANDBOX=1`.
|
||||
- A final setup summary that names the browser-dependent skills when Chromium is unavailable, lists any same-name skills left untouched, and reports any customized SKILL.md moved to `~/.gstack/backups/skills/<timestamp>/`.
|
||||
- `gstack-telemetry-log --no-sweep` for events that own no session.
|
||||
|
||||
#### For contributors
|
||||
- The time-attack fork evaluation that surfaced these defects is preserved under `docs/designs/fork-port-residual-2026-09/` (report, residual index, refutation verdicts, hashes). Waves B through E2 of that plan are scheduled work.
|
||||
- New free tests: `test/setup-link-ownership.test.ts`, plus large additions to `test/setup-playwright-best-effort.test.ts`, `test/relink.test.ts`, `test/hook-scripts.test.ts`, `test/telemetry.test.ts`. Anchor-sliced harnesses now fail loudly on `command not found` instead of degrading into "foreign, skipped".
|
||||
- `bin/gstack-relink` and `setup` carry the same ownership rule in two copies; the shared helper is filed in TODOS.md.
|
||||
|
||||
|
||||
## [1.79.0.0] - 2026-09-01
|
||||
|
||||
|
||||
@@ -286,6 +286,20 @@ Names are either short (`qa`) or namespaced (`gstack-qa`), controlled by
|
||||
`skill_prefix` in `~/.gstack/config.yaml`. Pass `--no-prefix` or `--prefix` to
|
||||
skip the interactive prompt.
|
||||
|
||||
**Ownership gate (#2119):** `setup` writes a `.gstack-owned` marker into every
|
||||
skill directory it creates, and `setup` (the linker, the alias installer, and
|
||||
both prefix-flip cleanups) and `bin/gstack-relink` only delete or link over an
|
||||
entry they can prove is gstack's. Strong proof (a symlink resolving into gstack,
|
||||
or the marker) allows deleting or refreshing the whole directory. Weak proof (a
|
||||
real SKILL.md byte-identical to the source, or carrying gen-skill-docs' two-line
|
||||
banner) covers only that one file, and a weakly-proven file that differs is
|
||||
moved to `~/.gstack/backups/skills/<ts>/<skill>/SKILL.md` before gstack links
|
||||
over it. Anything else is a foreign skill: skipped, and named in setup's final
|
||||
summary. The rule lives in two copies (`setup` and `bin/gstack-relink`); keep
|
||||
them in sync until the shared helper filed in TODOS.md lands. Pinned by
|
||||
`test/setup-link-ownership.test.ts`, `test/setup-cleanup-orphans.test.ts`, and
|
||||
`test/relink.test.ts`.
|
||||
|
||||
**Note:** Vendoring gstack into a project's repo is deprecated. Use global install
|
||||
+ `./setup --team` instead. See README.md for team mode instructions.
|
||||
|
||||
|
||||
+5
-1
@@ -474,6 +474,8 @@ When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It d
|
||||
- **`.claude/skills/` is gitignored.** The symlinks never get committed.
|
||||
- **Never write raw `ln -snf` in `setup`.** Every link site in `setup` MUST route through the `_link_or_copy SRC DST` helper near the `IS_WINDOWS` detection. The helper preserves `ln -snf` on Unix and switches to `cp -R` / `cp -f` on Windows without Developer Mode, where plain `ln -snf` produces frozen file copies that don't refresh on `git pull`. `test/setup-windows-fallback.test.ts` enforces this with a static invariant — a single raw `ln` call outside the helper body fails CI.
|
||||
- **Synchronous subagent dispatches must state the flag.** Claude Code runs Agent-tool subagents in the background by default (since v2.1.198), so any template step that dispatches a subagent and consumes its output must carry `run_in_background: false`. Use the `{{FOREGROUND_DISPATCH_NOTE}}` placeholder (`scripts/resolvers/constants.ts`) instead of hand-writing the guidance, and add the generated carrier file to `GENERATED_WITH_GUIDANCE` in `test/run-in-background-guidance.test.ts` in the same commit — its structural scanner fails CI on any generated dispatch imperative that lacks the flag.
|
||||
- **Never delete or link over a skill entry `setup` cannot prove is gstack's.** Every destructive site in `setup` (the linker, the alias installer, both prefix-flip cleanups) and in `bin/gstack-relink` goes through the ownership helpers (`_claude_entry_is_ours` / `_claude_entry_owned_strongly` in `setup`, `_entry_is_ours` / `_entry_owned_strongly` in relink). A symlink into gstack or the `.gstack-owned` marker proves the whole directory; a byte-identical or generated-banner SKILL.md proves only that file, and a differing one is moved to `~/.gstack/backups/skills/<ts>/` first. `test/setup-link-ownership.test.ts`, `test/setup-cleanup-orphans.test.ts`, and `test/relink.test.ts` pin it. The rule is duplicated in the two scripts until the shared helper filed in TODOS.md lands: change both.
|
||||
- **`./setup` never fails on Chromium.** The Playwright bootstrap (section `# 2` of `setup`) is best-effort and bounded: every failure becomes a reason code (`skipped`, `chromium-install`, `chromium-install-timeout`, `chromium-install-locked`, `windows-no-node`, `windows-node-modules`, `post-install-launch`) printed in the final summary alongside the browser-dependent skills, and skill registration always runs. `GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>` (default 600) bounds the download; `GSTACK_SKIP_PLAYWRIGHT=1` skips it, the right knob for a no-browser box or a setup-only test loop. Anything you add after the bootstrap must stay independent of the browser. `test/setup-playwright-best-effort.test.ts` pins the block.
|
||||
|
||||
## Testing your changes in a real project
|
||||
|
||||
@@ -527,7 +529,9 @@ cd .claude/skills/gstack && ./setup --no-prefix # switch to /qa, /ship
|
||||
cd .claude/skills/gstack && ./setup --prefix # switch to /gstack-qa, /gstack-ship
|
||||
```
|
||||
|
||||
Setup cleans up the old symlinks automatically. No manual cleanup needed.
|
||||
Setup cleans up the old symlinks automatically. No manual cleanup needed. Only
|
||||
entries gstack created are removed: a skill of your own that shares a name (a
|
||||
hand-written `qa/`, say) is left in place and named in setup's final summary.
|
||||
|
||||
### Alternative: point your global install at a branch
|
||||
|
||||
|
||||
@@ -398,6 +398,11 @@ while IFS= read -r dir; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# Directories gstack created carry a .gstack-owned marker (the only signal on
|
||||
# Windows, where installs are file copies with no symlink to read)
|
||||
for marker in ~/.claude/skills/*/.gstack-owned; do
|
||||
[ -f "$marker" ] && rm -rf "$(dirname "$marker")"
|
||||
done
|
||||
# Alias skills install as copies (no symlink to detect) — remove by name
|
||||
rm -rf ~/.claude/skills/_gstack-command ~/.claude/skills/connect-chrome 2>/dev/null
|
||||
|
||||
@@ -543,6 +548,32 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna
|
||||
|
||||
On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file copies instead of symlinks because `ln -snf` produces frozen copies that don't refresh on `git pull`. **Re-run `cd ~/.claude/skills/gstack && ./setup` after every `git pull`** so your skill files match the repo. `setup` prints a one-line note reminding you. Unix and WSL keep symlinks and don't need the re-run.
|
||||
|
||||
**Chromium install failed or hung during `./setup`?** The bundled browser is
|
||||
best-effort: setup records the reason, finishes registering every skill, and
|
||||
prints which skills are affected (`/qa`, `/qa-only`, `/design-review`,
|
||||
`/browse`, make-pdf, `/diagram`, `/pair-agent`). With Aside open, the browser
|
||||
skills keep running in Aside and only the fallback engine is missing;
|
||||
`/pair-agent` always needs the bundled browser. Fix the cause and re-run
|
||||
`./setup`. Knobs:
|
||||
`GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>` raises the download bound
|
||||
(default 600) on slow links; `GSTACK_SKIP_PLAYWRIGHT=1` skips the Chromium
|
||||
install entirely (CI, no-browser boxes); `GSTACK_CHROMIUM_NO_SANDBOX=1` is the
|
||||
fix when Chromium installs but cannot launch because the host blocks
|
||||
unprivileged user namespaces (Ubuntu 24.04+ AppArmor default, #2157).
|
||||
|
||||
**Setup ended with "Not registered (a skill you own already uses the name; left untouched)"?**
|
||||
gstack only deletes or links over a skill entry it can prove it created: a
|
||||
symlink into gstack, a directory carrying the `.gstack-owned` marker `./setup`
|
||||
writes into every directory it creates, or a SKILL.md that is byte-identical to
|
||||
gstack's or carries the generated `<!-- AUTO-GENERATED from ... -->` banner. A
|
||||
`qa/` or `ship/` you wrote yourself is left untouched by `./setup`,
|
||||
`gstack-relink`, and both prefix-mode flips, and the linker names it in the
|
||||
final summary. Rename or move yours, or switch modes (`./setup --prefix` /
|
||||
`--no-prefix`) so the names stop colliding. If you started your own skill from
|
||||
a generated gstack SKILL.md and then edited it, that file is moved to
|
||||
`~/.gstack/backups/skills/<timestamp>/<skill>/SKILL.md` before gstack's is
|
||||
linked in, never deleted.
|
||||
|
||||
**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this:
|
||||
|
||||
```
|
||||
|
||||
@@ -2,6 +2,59 @@
|
||||
|
||||
## NEXT PRIORITY
|
||||
|
||||
### P2: fork-port residual wave deferrals (filed at Wave A, 2026-09-03)
|
||||
|
||||
Filed from the time-attack/gstack residual evaluation
|
||||
(docs/designs/fork-port-residual-2026-09/REPORT.md) and its CEO + eng reviews.
|
||||
Waves B–E2 of that plan are scheduled work, not TODOs; these are the items the
|
||||
reviews deliberately deferred, each with rationale:
|
||||
|
||||
- **Shared `_gstack_owned_link` helper** — the ownership gate now exists in
|
||||
seven places (setup's `_claude_entry_is_ours` / `_claude_entry_owned_strongly`
|
||||
used by link_claude_skill_dirs and _install_alias_skill_md, while
|
||||
cleanup_old_claude_symlinks and cleanup_prefixed_claude_symlinks inline their
|
||||
own marker/cmp/banner chain and readlink `case`; bin/gstack-relink
|
||||
`_entry_is_ours`; bin/gstack-uninstall's per-entry loop; and, since the
|
||||
Aside-first wave, setup's `_prune_stale_generated`, which removes a retired
|
||||
host entry behind the banner-only `_owned_for_windows_refresh` check —
|
||||
symlinks outright, real dirs through `_cleanup_weak_dir` — and must route
|
||||
through the same helper). Extract one sourced
|
||||
helper so the destructive-path guard cannot drift, and while there: make the
|
||||
`.gstack-owned` marker's recorded install path load-bearing (today any marker
|
||||
counts, so a Windows fork copy carrying gstack's generated header is still
|
||||
treated as ours on a mode flip). Effort S. Priority P2. Depends on: none.
|
||||
- **Config-key reader tripwire** — `transcript_ingest_mode=off` sat unread for
|
||||
months while setup-gbrain advertised it. A free test that asserts every key
|
||||
in bin/gstack-config's default table is read by at least one binary (or is
|
||||
explicitly listed as prose-only) makes a dead consent switch a red test.
|
||||
Effort S. Priority P2. Depends on: Wave E1 landing the reader.
|
||||
- **"Pre-existing" failure vocabulary** — scripts/resolvers/preamble/
|
||||
generate-test-failure-triage.ts classifies from `git diff --name-only` and
|
||||
never asks for a base-branch run. Rewrite T1 to verified/unverified with the
|
||||
base branch's CI status (`gh run list --branch <base>`) as default evidence
|
||||
and a failing-files-only worktree run as an opt-in. Effort M → S with CC.
|
||||
Priority P2. Depends on: none.
|
||||
- **Opt-in `reply_language` config key** (#679) — render into the Writing
|
||||
Style section only when set; keep identifiers and commands in English; add
|
||||
the mixed-language tests the issue asked for. Not an always-on voice line
|
||||
(community-PR guardrail). Effort S. Priority P3.
|
||||
- **Remove the `~/.gstack/.auth.json` writer** — browser-manager.ts:638-640
|
||||
says the component-baked GBrowser extension reads it. Confirm GBrowser
|
||||
bootstraps via `POST /extension-token`; if so, delete the writer plus a
|
||||
migration that removes the orphaned credential file. Effort S. Priority P3.
|
||||
Depends on: GBrowser source check.
|
||||
- **CONTRIBUTING rule for fork-derived changes** — a change lifted from a fork
|
||||
enters upstream only behind a test verified red on upstream HEAD first, with
|
||||
credit to the original author; cherry-picks allowed when the fork commit
|
||||
carries that test. 34 of 48 top fork candidates died under refutation; the
|
||||
rule is what made the survivors safe. Effort S. Priority P2.
|
||||
- **Hook slug-derivation parity audit** — question-preference-hook keyed
|
||||
project prefs by cwd basename while the writer keyed by owner-repo (Wave E1
|
||||
fixes it via `slugFromCacheOnly`). Audit question-log-hook and every other
|
||||
Claude hook that buckets by project for the same mismatch. Effort S.
|
||||
Priority P3. Depends on: Wave E1.
|
||||
|
||||
|
||||
### P1: ZeroEntropy sunset — gbrain's default embedding provider dies Sept 4, 2026 (#2365)
|
||||
|
||||
**What:** ZeroEntropy (acquired by Notion) shuts down September 4, 2026. gbrain's
|
||||
@@ -196,10 +249,11 @@ The wave's Step-7 coverage audit (5 subsystem agents, ~700 changed paths,
|
||||
shipped verified by hand or adjacent tests); each is a cheap pin against
|
||||
silent regression:
|
||||
|
||||
- **setup Playwright bootstrap block** — `_clear_playwright_quarantine`,
|
||||
`_PW_LOCK` stale-holder reclaim, `_kill_tree`/`_wait_with_deadline`, Ubuntu
|
||||
26.04 platform override: zero test references. The P0 #2554 heal's shell
|
||||
half. Effort S each.
|
||||
- **setup Playwright bootstrap block** — `_PW_LOCK` stale-holder reclaim,
|
||||
`_kill_tree`/`_wait_with_deadline`, and the platform override are now pinned
|
||||
by test/setup-playwright-best-effort.test.ts (fork-port Wave A). Still
|
||||
unpinned: `_clear_playwright_quarantine` (the P0 #2554 heal's shell half).
|
||||
Effort S.
|
||||
- **redact-prepush `scanAddedLines` slicing** — the >1MiB catch-up-diff chunk
|
||||
path (the reason the function exists) is unexercised; a regression
|
||||
reintroduces blocking-while-unscanned. Effort S.
|
||||
@@ -3949,13 +4003,18 @@ globs (D). What remains, re-filed individually:
|
||||
darwin-skipped handoff tests in browse/test/handoff.test.ts — verify
|
||||
whether the v1.67 XProtect + rebrand work un-blocks them, then un-skip or
|
||||
fix. Effort S.
|
||||
- Transcript trust/scope/source isolation (PR 2232, issue 2140) — needs the
|
||||
never-double-store review. Effort M.
|
||||
- Transcript trust/scope/source isolation (PR 2232, issue 2140) — split:
|
||||
the `transcript_ingest_mode` reader (off skips, B → --all-history, unset
|
||||
unchanged) ships in fork-port Wave E1; repo-scoping and `--source-id`
|
||||
isolation still need the never-double-store review plus a gbrain flag
|
||||
probe. Close the PR after E1 with a pointer here. Effort M.
|
||||
- Versionless-repo onboarding (#1474, issues 2343/2334) — the #2501 JSON
|
||||
version-path half landed; the no-version-file-at-all flow did not.
|
||||
- Playwright bootstrap abort/timeout absorbs (PRs 2233/2359, issues
|
||||
1902/2136) — partially superseded by v1.67's bounded bootstrap; verify
|
||||
and close or extract the remainder.
|
||||
1902/2136) — DONE in fork-port Wave A: the install is best-effort and
|
||||
bounded (GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT, default 600s), lock contention
|
||||
is a reason code, skills always register. Close #2233, #1900, #1901, #1902,
|
||||
#913 with the receipt (test/setup-playwright-best-effort.test.ts).
|
||||
|
||||
## Aside-first follow-ups (filed when Aside became the primary browser)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.80.0.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.81.0.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+260
-6
@@ -42,20 +42,232 @@ PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
|
||||
# every skill back to the canonical (blockless) source.
|
||||
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
|
||||
|
||||
# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md)
|
||||
# ─── Ownership gate ───────────────────────────────────────────────────────────
|
||||
# relink runs on every ./setup and used to `rm -rf` any same-name entry with a
|
||||
# symlinked SKILL.md and `ln -snf` over any existing SKILL.md — so a user's own
|
||||
# skill that happened to share a name (a personal `qa`, a fork under another
|
||||
# path) was deleted or had its SKILL.md replaced by a symlink into gstack
|
||||
# (#2119; Linux replaces a real file with `ln -snf`, macOS refuses by accident).
|
||||
# setup (link_claude_skill_dirs, cleanup_old_claude_symlinks,
|
||||
# cleanup_prefixed_claude_symlinks) applies the same rule; keep the two files
|
||||
# in sync until the shared helper filed in TODOS.md lands. gstack-uninstall
|
||||
# has its own stricter inventory+banner gate.
|
||||
#
|
||||
# Proof comes in two strengths. STRONG (a symlink resolving into gstack, or the
|
||||
# .gstack-owned marker) means we created the entry: it may be deleted whole or
|
||||
# refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs'
|
||||
# two-line banner on a real file) proves only that the SKILL.md came from us:
|
||||
# it authorizes touching that one file, never deleting the directory, and a
|
||||
# differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/
|
||||
# before we link over it (a user who started their own skill from a gstack
|
||||
# SKILL.md looks exactly like a pre-marker legacy copy).
|
||||
#
|
||||
# An entry is OURS when:
|
||||
# - it is a symlink resolving into $INSTALL_DIR or $RENDER_DIR (as written or
|
||||
# as realpath), or into any path with a `gstack` segment — the convention
|
||||
# setup and gstack-uninstall use, so a sibling worktree's entries and a
|
||||
# moved checkout's dangling links still count as ours, or
|
||||
# - it is a real dir whose SKILL.md is such a symlink, or
|
||||
# - it is a real dir with a real-file SKILL.md proven by the .gstack-owned
|
||||
# marker (Windows copy installs), byte-identity with our source, or
|
||||
# gen-skill-docs' generated header (legacy copies made before the marker).
|
||||
# Anything else — a foreign symlink, a real dir with a hand-written SKILL.md,
|
||||
# or an entry whose readlink fails — is FOREIGN: never deleted, never linked
|
||||
# over, reported on stderr.
|
||||
# The install/render roots as written AND as resolved: a standalone relink
|
||||
# detects INSTALL_DIR as ~/.claude/skills/gstack, which may itself be a symlink
|
||||
# to a checkout, while setup linked entries against the checkout's real path.
|
||||
# Both spellings are ours. (Roots stay quoted inside the case patterns, so a
|
||||
# glob character or space in a path is matched literally.)
|
||||
_INSTALL_REAL="$(cd "$INSTALL_DIR" 2>/dev/null && pwd -P || printf '%s' "$INSTALL_DIR")"
|
||||
_RENDER_REAL="$(cd "$RENDER_DIR" 2>/dev/null && pwd -P || printf '%s' "$RENDER_DIR")"
|
||||
_target_is_ours() {
|
||||
# $1 = an ABSOLUTE path a symlink resolves to; ours when it lives under one
|
||||
# of our roots. "$ROOT"/* requires the separator, so /home/u/gstack2/x never
|
||||
# matches a /home/u/gstack root.
|
||||
local root
|
||||
case "$1" in
|
||||
"$INSTALL_DIR"/*|"$RENDER_DIR"/*|"$_INSTALL_REAL"/*|"$_RENDER_REAL"/*) return 0 ;;
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;;
|
||||
esac
|
||||
# A checkout named without a `gstack` segment (git worktree add
|
||||
# ../gstack-<branch>): the target's skill root is a gstack tree if it
|
||||
# carries setup + VERSION + bin/gstack-relink (a hand-written skill repo with
|
||||
# a VERSION file does not). Same rule as setup's _gstack_target_is_ours.
|
||||
root="${1%/*/SKILL.md}"
|
||||
if [ "$root" != "$1" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# readlink of a RELATIVE symlink (older installs wrote `gstack/qa/SKILL.md`)
|
||||
# is relative to the link's own directory, not to $PWD. Anchor it there, then
|
||||
# canonicalize the DIRECTORY part (pwd -P) so `..` segments and symlinked
|
||||
# path components (a `gstack` alias dir, a symlinked install) compare against
|
||||
# the real roots. The basename is kept verbatim: canonicalizing it would follow
|
||||
# the final link and turn every dangling target into "not ours".
|
||||
_link_target_abs() {
|
||||
local link="$1" dest d b d_real
|
||||
dest="$(readlink "$link" 2>/dev/null || true)"
|
||||
[ -n "$dest" ] || return 1
|
||||
case "$dest" in
|
||||
/*) ;;
|
||||
*) dest="${link%/*}/$dest" ;;
|
||||
esac
|
||||
d="${dest%/*}"; b="${dest##*/}"
|
||||
if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then
|
||||
printf '%s\n' "$d_real/$b"
|
||||
else
|
||||
printf '%s\n' "$dest"
|
||||
fi
|
||||
}
|
||||
|
||||
# _entry_owned_strongly ENTRY — we created this entry: a symlink resolving
|
||||
# into gstack, or a real dir carrying the .gstack-owned marker or a SKILL.md
|
||||
# symlink into gstack.
|
||||
_entry_owned_strongly() {
|
||||
local entry="$1" dest
|
||||
if [ -L "$entry" ]; then
|
||||
dest="$(_link_target_abs "$entry")" || return 1
|
||||
_target_is_ours "$dest"
|
||||
return $?
|
||||
fi
|
||||
[ -d "$entry" ] || return 1
|
||||
[ -f "$entry/.gstack-owned" ] && return 0
|
||||
if [ -L "$entry/SKILL.md" ]; then
|
||||
dest="$(_link_target_abs "$entry/SKILL.md")" || return 1
|
||||
_target_is_ours "$dest"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# _entry_is_ours ENTRY SKILL — strong proof, or WEAK proof on a real-file
|
||||
# SKILL.md (SKILL names the gstack skill this entry would serve, so the copy
|
||||
# can be compared against our own source).
|
||||
_entry_is_ours() {
|
||||
local entry="$1" skill="${2:-}" src
|
||||
_entry_owned_strongly "$entry" && return 0
|
||||
# A symlink that did not resolve into gstack is someone else's; never follow
|
||||
# it into the "unclaimed directory" rule below.
|
||||
[ -L "$entry" ] && return 1
|
||||
if [ -d "$entry" ] && [ ! -L "$entry/SKILL.md" ]; then
|
||||
# No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the
|
||||
# user's other files behind, or the dir was never a skill). Adding our
|
||||
# SKILL.md overwrites nothing, so the link pass may proceed; the cleanup
|
||||
# pass has nothing of ours to remove (see _cleanup_skill_entry).
|
||||
[ -e "$entry/SKILL.md" ] || return 0
|
||||
if [ -f "$entry/SKILL.md" ]; then
|
||||
for src in "$RENDER_DIR/$skill/SKILL.md" "$INSTALL_DIR/$skill/SKILL.md"; do
|
||||
[ -n "$skill" ] && [ -f "$src" ] && cmp -s "$entry/SKILL.md" "$src" && return 0
|
||||
done
|
||||
# Pre-marker legacy copy: gen-skill-docs' full two-line banner near the
|
||||
# top (same rule as setup's _gstack_generated_header), not a one-line
|
||||
# substring another generator could emit. A gstack fork rendering the
|
||||
# same banner is the accepted, filed residual.
|
||||
case "$(head -c 8192 "$entry/SKILL.md" 2>/dev/null)" in
|
||||
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
FOREIGN_SKIPPED=()
|
||||
_report_foreign() {
|
||||
# Same wording and bare name as setup's own line, so setup can dedupe when it
|
||||
# forwards relink's output.
|
||||
echo " skipped ${1##*/}: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
||||
FOREIGN_SKIPPED+=("${1##*/}")
|
||||
}
|
||||
|
||||
# Weakly-proven real files we would otherwise overwrite go here, one summary
|
||||
# line at the end. mv, not cp: the link that follows needs the path free.
|
||||
BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)"
|
||||
BACKED_UP=()
|
||||
_backup_skill_md() {
|
||||
# Non-zero when the file could NOT be moved: the caller leaves the entry alone.
|
||||
local file="$1" name="$2"
|
||||
mkdir -p "$BACKUP_ROOT/$name" 2>/dev/null || return 1
|
||||
mv -f "$file" "$BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1
|
||||
BACKED_UP+=("$name")
|
||||
return 0
|
||||
}
|
||||
|
||||
# Helper: remove an OLD skill entry from the opposite prefix mode. Only entries
|
||||
# we can prove are ours are removed; anything else is reported and kept.
|
||||
_cleanup_skill_entry() {
|
||||
local entry="$1"
|
||||
local entry="$1" skill="${2:-}" e dest src
|
||||
[ -e "$entry" ] || [ -L "$entry" ] || return 0
|
||||
# Unclaimed dir (no SKILL.md, no marker): nothing of ours to clean.
|
||||
if [ -d "$entry" ] && [ ! -L "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ] && [ ! -f "$entry/.gstack-owned" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! _entry_is_ours "$entry" "$skill"; then
|
||||
_report_foreign "$entry"
|
||||
return 0
|
||||
fi
|
||||
if [ -L "$entry" ]; then
|
||||
rm -f "$entry"
|
||||
elif [ -d "$entry" ] && [ -L "$entry/SKILL.md" ]; then
|
||||
rm -rf "$entry"
|
||||
elif [ -d "$entry" ]; then
|
||||
# Whole-directory removal needs proof that nothing of the user's is inside:
|
||||
# the marker (we created the dir) or a directory holding nothing but links.
|
||||
if [ -f "$entry/.gstack-owned" ] || { [ -L "$entry/SKILL.md" ] && _dir_only_links "$entry"; }; then
|
||||
rm -rf "$entry"
|
||||
else
|
||||
# Otherwise only what is ours goes: the SKILL.md, the marker, and our
|
||||
# runtime-asset links. The user's files stay, and so does the directory
|
||||
# if it is not empty afterwards. A real SKILL.md that differs from our
|
||||
# source (raw, or with its name: rewritten to the entry name) is a
|
||||
# customized file: moved to the backup root, never deleted.
|
||||
if [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ] && [ -n "$skill" ]; then
|
||||
src="$INSTALL_DIR/$skill/SKILL.md"; [ -f "$RENDER_DIR/$skill/SKILL.md" ] && src="$RENDER_DIR/$skill/SKILL.md"
|
||||
if [ -f "$src" ] && ! cmp -s "$entry/SKILL.md" "$src" \
|
||||
&& ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: ${entry##*/}/" "$src" | cmp -s - "$entry/SKILL.md"; then
|
||||
if ! _backup_skill_md "$entry/SKILL.md" "${entry##*/}"; then
|
||||
echo " kept ${entry##*/}/SKILL.md: could not back up the customized file — left untouched" >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
rm -f "$entry/SKILL.md" "$entry/.gstack-owned"
|
||||
for e in "$entry"/* "$entry"/.[!.]* "$entry"/..?*; do
|
||||
[ -L "$e" ] || continue
|
||||
dest="$(_link_target_abs "$e")" || continue
|
||||
if _target_is_ours "$dest"; then rm -f "$e"; fi
|
||||
done
|
||||
rmdir "$entry" 2>/dev/null || echo " cleaned ${entry##*/}/SKILL.md (other files in that directory were left in place)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
# _dir_only_links DIR — deleting DIR whole loses no real data: every entry is
|
||||
# a symlink or our marker.
|
||||
_dir_only_links() {
|
||||
# Every entry must be a symlink resolving into gstack, or our marker: a
|
||||
# user's own link (notes.md -> ~/notes) makes the directory mixed.
|
||||
local d="$1" e dest
|
||||
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
||||
{ [ -e "$e" ] || [ -L "$e" ]; } || continue
|
||||
[ "${e##*/}" = ".gstack-owned" ] && continue
|
||||
[ -L "$e" ] || return 1
|
||||
dest="$(_link_target_abs "$e")" || return 1
|
||||
_target_is_ours "$dest" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_link_root_skill_alias() {
|
||||
local target="$SKILLS_DIR/_gstack-command"
|
||||
|
||||
[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
|
||||
# Same ownership gate as every other entry (#2119): a user's own
|
||||
# `_gstack-command` skill is reported and left alone, never overwritten.
|
||||
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target" ""; then
|
||||
_report_foreign "$target"
|
||||
return 0
|
||||
fi
|
||||
local pre=0
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then pre=1; fi
|
||||
[ -L "$target" ] && rm -f "$target"
|
||||
mkdir -p "$target"
|
||||
# Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves
|
||||
@@ -65,6 +277,12 @@ _link_root_skill_alias() {
|
||||
# write through it into the generated source.
|
||||
rm -f "$target/SKILL.md"
|
||||
sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md"
|
||||
# The rewritten copy is a real file on every platform: the marker, not the
|
||||
# banner, is what proves it ours on the next run — written only for a
|
||||
# directory we created (or already marked), never one we merely wrote into.
|
||||
if [ "$pre" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then
|
||||
printf '%s\n' "$_INSTALL_REAL" > "$target/.gstack-owned" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
_link_root_skill_alias
|
||||
@@ -90,23 +308,53 @@ for skill_dir in "$INSTALL_DIR"/*/; do
|
||||
*) link_name="gstack-$skill" ;;
|
||||
esac
|
||||
# Remove old flat entry if it exists (and isn't the same as the new link)
|
||||
[ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill"
|
||||
[ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill" "$skill"
|
||||
else
|
||||
link_name="$skill"
|
||||
# Don't remove gstack-* dirs that are their real name (e.g., gstack-upgrade)
|
||||
case "$skill" in
|
||||
gstack-*) ;; # Already the real name, no old prefixed link to clean
|
||||
*) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;;
|
||||
*) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" "$skill" ;;
|
||||
esac
|
||||
fi
|
||||
target="$SKILLS_DIR/$link_name"
|
||||
# A destination that already exists and is NOT ours is a foreign skill that
|
||||
# shares our name. Never `ln -snf` over its SKILL.md (on Linux that replaces
|
||||
# a real file with a symlink into gstack) and never mkdir into it — skip
|
||||
# loudly and leave registration of that one name to the user.
|
||||
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target" "$skill"; then
|
||||
_report_foreign "$target"
|
||||
continue
|
||||
fi
|
||||
# Remember whether WE are creating this directory: only then may the marker
|
||||
# below make it deletable whole. A directory we merely link into (unclaimed,
|
||||
# or a legacy install) never gets one — legacy all-links dirs are removed by
|
||||
# the only-links rule instead.
|
||||
_pre_exists=0
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then _pre_exists=1; fi
|
||||
# Upgrade old directory symlinks to real directories
|
||||
[ -L "$target" ] && rm -f "$target"
|
||||
# Create real directory with symlinked SKILL.md (absolute path)
|
||||
mkdir -p "$target"
|
||||
skill_md_src="$INSTALL_DIR/$skill/SKILL.md"
|
||||
[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"
|
||||
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
|
||||
# differs from what we are about to serve is moved aside, not overwritten.
|
||||
if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _entry_owned_strongly "$target" \
|
||||
&& ! cmp -s "$target/SKILL.md" "$skill_md_src"; then
|
||||
if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
|
||||
echo " skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
|
||||
FOREIGN_SKIPPED+=("$link_name")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
ln -snf "$skill_md_src" "$target/SKILL.md"
|
||||
# Provenance marker on every platform (path-independent proof; on Windows
|
||||
# without Developer Mode `ln -snf` degrades to a copy and this is the only
|
||||
# proof), but only for a directory we created or already owned.
|
||||
if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then
|
||||
printf '%s\n' "$_INSTALL_REAL" > "$target/.gstack-owned" 2>/dev/null || true
|
||||
fi
|
||||
SKILL_COUNT=$((SKILL_COUNT + 1))
|
||||
done
|
||||
|
||||
@@ -124,3 +372,9 @@ if [ "$PREFIX" = "true" ]; then
|
||||
else
|
||||
echo "Relinked $SKILL_COUNT skills as flat names"
|
||||
fi
|
||||
if [ ${#BACKED_UP[@]} -gt 0 ]; then
|
||||
echo "Moved ${#BACKED_UP[@]} pre-existing SKILL.md file(s) to $BACKUP_ROOT before linking gstack's: ${BACKED_UP[*]}"
|
||||
fi
|
||||
if [ ${#FOREIGN_SKIPPED[@]} -gt 0 ]; then
|
||||
echo "Skipped ${#FOREIGN_SKIPPED[@]} foreign entr$( [ ${#FOREIGN_SKIPPED[@]} -eq 1 ] && echo y || echo ies) (not gstack-managed, left untouched): ${FOREIGN_SKIPPED[*]}"
|
||||
fi
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
# gstack-telemetry-log --skill qa --duration 142 --outcome success \
|
||||
# --used-browse true --session-id "12345-1710756600"
|
||||
#
|
||||
# --no-sweep one-shot events with no session of their own (setup's
|
||||
# onboarding events) must not finalize OTHER sessions' in-flight
|
||||
# .pending markers as outcome:unknown — only a skill's own
|
||||
# epilogue may sweep.
|
||||
#
|
||||
# Env overrides (for testing):
|
||||
# GSTACK_STATE_DIR — override ~/.gstack state directory
|
||||
# GSTACK_DIR — override auto-detected gstack root
|
||||
@@ -48,9 +53,11 @@ SEC_PAYLOAD_HASH=""
|
||||
SEC_CONFIDENCE=""
|
||||
SEC_LAYER=""
|
||||
SEC_VERDICT=""
|
||||
NO_SWEEP=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--no-sweep) NO_SWEEP=1; shift ;;
|
||||
--skill) SKILL="$2"; shift 2 ;;
|
||||
--duration) DURATION="$2"; shift 2 ;;
|
||||
--outcome) OUTCOME="$2"; shift 2 ;;
|
||||
@@ -93,6 +100,9 @@ fi
|
||||
# ─── Finalize stale .pending markers ────────────────────────
|
||||
# Each session gets its own .pending-$SESSION_ID file to avoid races
|
||||
# between concurrent sessions. Finalize any that don't match our session.
|
||||
# --no-sweep (one-shot events from setup) skips this: a marker that is not
|
||||
# ours is another live session's, not a stale one.
|
||||
if [ -z "$NO_SWEEP" ]; then
|
||||
for PFILE in "$PENDING_DIR"/.pending-*; do
|
||||
[ -f "$PFILE" ] || continue
|
||||
# Skip our own session's marker (it's still in-flight)
|
||||
@@ -117,6 +127,7 @@ for PFILE in "$PENDING_DIR"/.pending-*; do
|
||||
"$P_TS" "$P_SKILL" "$P_SID" "$P_VER" "$P_OS" "$P_ARCH" >> "$JSONL_FILE" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Clear our own session's pending marker (we're about to log the real event)
|
||||
[ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true
|
||||
|
||||
@@ -265,7 +265,15 @@ fi
|
||||
# ERE per line; blank lines and #-comments skipped; an invalid regex is
|
||||
# skipped (never fatal — the hook must not break on a typo in config).
|
||||
if [ -z "$WARN" ]; then
|
||||
_GSTACK_HOME_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
# Same state root the writer (/careful via gstack-paths) uses — see
|
||||
# gstack_hook_state_root in hook-extract.sh (#1459 class).
|
||||
if command -v gstack_hook_state_root >/dev/null 2>&1; then
|
||||
_GSTACK_HOME_DIR="$(gstack_hook_state_root; printf x)"; _GSTACK_HOME_DIR="${_GSTACK_HOME_DIR%x}"
|
||||
else
|
||||
# Older hook-extract.sh (partial upgrade): the plain chain beats dying
|
||||
# under set -e with no decision JSON — rules under $HOME/.gstack still load.
|
||||
_GSTACK_HOME_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
fi
|
||||
_PATTERN_FILES="$_GSTACK_HOME_DIR/careful-patterns.txt"
|
||||
# Short-circuit: resolving the project slug costs a subprocess + git call on
|
||||
# EVERY Bash command while /careful is active — only pay it when some
|
||||
|
||||
@@ -63,10 +63,39 @@ gstack_hook_decision() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
|
||||
}
|
||||
|
||||
# gstack_hook_state_root
|
||||
# Print the gstack state root, resolved with EXACTLY the chain bin/gstack-paths
|
||||
# uses (GSTACK_STATE_ROOT): GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when
|
||||
# CLAUDE_PLUGIN_ROOT names gstack (a CLAUDE_PLUGIN_DATA leaked from another
|
||||
# plugin via CLAUDE_ENV_FILE must not redirect our state), then $HOME/.gstack,
|
||||
# then a project-local .gstack. Hooks run on every Edit/Bash call, so this is
|
||||
# pure bash — never spawn gstack-paths from a hook. The writers (/freeze,
|
||||
# /guard, /unfreeze, /investigate) resolve through gstack-paths; a reader that
|
||||
# used a different chain failed OPEN whenever GSTACK_HOME was set (#1459).
|
||||
# test/hook-scripts.test.ts pins parity against gstack-paths.
|
||||
# Printed WITHOUT a trailing newline: callers capture with a sentinel
|
||||
# (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that
|
||||
# itself ends in a newline round-trips exactly as gstack-paths' %q does —
|
||||
# otherwise writer and reader would again disagree on the directory.
|
||||
gstack_hook_state_root() {
|
||||
if [ -n "${GSTACK_HOME:-}" ]; then
|
||||
printf '%s' "$GSTACK_HOME"
|
||||
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && printf '%s' "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then
|
||||
printf '%s' "$CLAUDE_PLUGIN_DATA"
|
||||
elif [ -n "${HOME:-}" ]; then
|
||||
printf '%s' "$HOME/.gstack"
|
||||
else
|
||||
printf '%s' ".gstack"
|
||||
fi
|
||||
}
|
||||
|
||||
# gstack_hook_log_fire SKILL PATTERN
|
||||
# Append a hook_fire analytics record (pattern name only, never command
|
||||
# content). Respects GSTACK_HOME so tests never pollute the operator's real
|
||||
# analytics file. Best-effort: failures never affect the hook decision.
|
||||
# analytics file. Deliberately NOT gstack_hook_state_root: every other
|
||||
# analytics writer and reader (gstack-skill-start, gstack-retro-metrics,
|
||||
# gstack-analytics) uses this two-step chain, and the usage log must stay one
|
||||
# file. Best-effort: failures never affect the hook decision.
|
||||
gstack_hook_log_fire() {
|
||||
_ghlf_dir="${GSTACK_HOME:-$HOME/.gstack}/analytics"
|
||||
mkdir -p "$_ghlf_dir" 2>/dev/null || true
|
||||
|
||||
@@ -39,6 +39,7 @@ gstack/
|
||||
│ ├── aside-render.test.ts # Tier 1 pins + a live Aside render (self-skips without Aside)
|
||||
│ ├── skill-validation.test.ts # Tier 1: static validation (free, <1s)
|
||||
│ ├── gen-skill-docs.test.ts # Tier 1: generator quality (free, <1s)
|
||||
│ ├── setup-*.test.ts, relink.test.ts, hook-scripts.test.ts # Tier 1: setup linker ownership + Chromium bootstrap (anchor-sliced from setup), gstack-relink, PreToolUse hooks (free)
|
||||
│ ├── skill-llm-eval.test.ts # Tier 3: LLM-as-judge (~$0.15/run)
|
||||
│ └── skill-e2e-*.test.ts # Tier 2: E2E via claude -p (~$3.85/run, split by category)
|
||||
├── qa-only/ # /qa-only skill (report-only QA, no fixes)
|
||||
@@ -57,7 +58,11 @@ gstack/
|
||||
├── investigate/ # /investigate skill (systematic root-cause debugging)
|
||||
├── spec/ # /spec skill (five-phase spec → GitHub issue, optional agent spawn, /ship auto-closes)
|
||||
├── retro/ # Retrospective skill (includes /retro global cross-project mode)
|
||||
├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, etc.)
|
||||
├── careful/ # /careful skill; bin/check-careful.sh (PreToolUse destructive-command hook) + bin/hook-extract.sh (shared hook helpers: payload extraction, deny JSON, gstack_hook_state_root)
|
||||
├── freeze/ # /freeze skill; bin/check-freeze.sh (PreToolUse edit-boundary hook; sources careful/bin/hook-extract.sh, fails closed)
|
||||
├── guard/, unfreeze/ # /guard (careful + freeze in one), /unfreeze
|
||||
├── gstack-upgrade/ # /gstack-upgrade skill + migrations/ (run after ./setup during an upgrade)
|
||||
├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, gstack-relink, etc.)
|
||||
├── document-release/ # /document-release skill (post-ship doc updates + Diataxis coverage map)
|
||||
├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation)
|
||||
├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit)
|
||||
@@ -78,14 +83,14 @@ gstack/
|
||||
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
|
||||
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
|
||||
├── patches/ # bun `patchedDependencies` patches (playwright-core windowsHide)
|
||||
├── docs/designs/ # Design documents
|
||||
├── docs/designs/ # Design documents (incl. fork-port-residual-2026-09/ evaluation evidence)
|
||||
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
|
||||
├── .github/ # CI workflows + shared composite actions (.github/actions/) + Docker image (claude CLI pinned)
|
||||
│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, and 8 more (windows, periodic evals, release gates, ci-image)
|
||||
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
|
||||
├── contrib/ # Contributor-only tools (never installed for users)
|
||||
│ └── add-host/ # /gstack-contrib-add-host skill
|
||||
├── setup # One-time setup: build the browse, design + make-pdf binaries, symlink skills
|
||||
├── setup # One-time setup: build the browse, design + make-pdf binaries + best-effort Chromium bootstrap + link skills (ownership-gated)
|
||||
├── SKILL.md # Generated from SKILL.md.tmpl (don't edit directly)
|
||||
├── SKILL.md.tmpl # Template: edit this, run gen:skill-docs
|
||||
├── ETHOS.md # Builder philosophy (Boil the Ocean, Search Before Building)
|
||||
|
||||
@@ -46,7 +46,7 @@ a baseline, so a run can't compare against itself).
|
||||
|
||||
**Aside-driven E2E tests self-skip without a live Aside.** Every skill that
|
||||
opens a web page drives the Aside AI browser first (`scripts/resolvers/aside.ts`)
|
||||
and falls back to gstack's own browse engine when Aside is absent. The E2E
|
||||
and falls back to gstack's own browse engine when Aside is absent (and Chromium bootstrapped). The E2E
|
||||
cases that exercise the Aside path (`test/skill-e2e-aside.test.ts`, the Aside
|
||||
`qa-*` cases in `test/skill-e2e-qa-workflow.test.ts` / `skill-e2e-qa-bugs.test.ts`,
|
||||
`design-review-fix` in `test/skill-e2e-design.test.ts`) call `asideAvailable()`
|
||||
@@ -69,6 +69,12 @@ compiles, and they skip only when neither exists. Only
|
||||
`test/aside-render.test.ts`'s single live round-trip is Aside-only (its option
|
||||
mapping and generated-script pins run everywhere). A green gate on Linux proves
|
||||
the fallback engine, not Aside; the Mac run is the Aside evidence.
|
||||
The browse-binary leg presumes Chromium bootstrapped: `resolveBrowseBin()`
|
||||
only checks that the binary (or the `find-browse` shim) exists, never that
|
||||
Chromium can launch, so on an install where the best-effort Chromium step
|
||||
was skipped (`GSTACK_SKIP_PLAYWRIGHT=1`) or failed, these gates run and fail
|
||||
at browser launch instead of skipping. Fix the bootstrap (or move the binary
|
||||
aside) before running them locally; CI always installs Chromium first.
|
||||
|
||||
**Free suite (`bun run test:free`).** `scripts/test-free-shards.ts` runs N
|
||||
concurrent shard processes (serial within each) with strict-output
|
||||
@@ -148,6 +154,26 @@ in the test trees must carry a `timeout`, enforced by
|
||||
`test/spawnsync-timeout-tripwire.test.ts` with a shrink-only exemption
|
||||
ratchet.
|
||||
|
||||
**Anchor-sliced `setup` harnesses.** `setup` is one large bash script, so the
|
||||
free tests that pin its linker, cleanup, and Chromium-bootstrap behavior never
|
||||
run the whole thing. They slice the source by anchor (`extractFn(name)` takes
|
||||
`name() {` through the next `\n}\n`; `test/setup-playwright-best-effort.test.ts`
|
||||
slices the `# 2. Ensure Playwright's Chromium is available` block up to
|
||||
`# 2b.`), join the extracted functions with stubbed collaborators, and execute
|
||||
the REAL bash under a temp `HOME` with stubbed probes and installers. Two rules
|
||||
keep the harness honest: renaming a function or anchor comment in `setup` fails
|
||||
the test with `function not found` / `anchor not found` instead of silently
|
||||
testing nothing, and `test/setup-link-ownership.test.ts` and
|
||||
`test/setup-playwright-best-effort.test.ts` throw on any `command not found` on
|
||||
stderr as harness drift (a helper the test forgot to extract) rather than
|
||||
letting it degrade into a pass. Files: `test/setup-link-ownership.test.ts`,
|
||||
`test/setup-cleanup-orphans.test.ts`, `test/setup-playwright-best-effort.test.ts`.
|
||||
`test/relink.test.ts` shells out to a copy of the real `bin/gstack-relink`
|
||||
against a temp `GSTACK_INSTALL_DIR` / `GSTACK_SKILLS_DIR`, and
|
||||
`test/hook-scripts.test.ts` runs the real `careful/bin/check-careful.sh` and
|
||||
`freeze/bin/check-freeze.sh` with JSON payloads on stdin (including the
|
||||
`GSTACK_HOME` state-root parity against `bin/gstack-paths`).
|
||||
|
||||
## Cloud sandboxes (Vercel / Conductor cloud workspaces)
|
||||
|
||||
Syscall-supervised sandboxes need environment setup before `bun run test` can
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Is there anything else worth taking in from time-attack/gstack?
|
||||
|
||||
Evaluation of the time-attack/gstack fork (Sina Matian, MIT; fork main 0aca1f77, tip of the "GStack 2 public beta" release merged as PR #53 at 41927ca8, 2026-08-10; merge-base 7c9df1c5) against upstream garrytan/gstack main v1.79.0.0 (2026-09-01). Sources: /tmp/ta-eval/summary.json, residual-index.json (287 items, read in full), records-other.json (128 items), records-residual.json (per-item lookups), refute-batch-1..6.json (48 items) and the appended adversarial refuter verdicts. Upstream claims below were re-checked by grep at HEAD where cited as file:line.
|
||||
|
||||
## 1. Bottom line
|
||||
|
||||
After the seven directly merged fork PRs, the iOS suite, port waves 1 and 2 (v1.63, v1.65) and the security sweep (v1.67.1), the fork's 583 raw candidates collapse to 415 canonical items, of which 128 are already absorbed, superseded or not applicable and 287 carry some residual. The panel rated 269 of those 287 as take or take-partial, but when the 48 top-ranked candidates were put through adversarial refutation only 14 survived, and two of those are the same defect counted twice, so the honest answer is about a dozen genuine ports plus a cluster of ten small grep-verified bin/browse fixes, with a further 39 panel-P1 items that were never refuted and are listed in section 5a so nothing is silently misfiled. Almost none of it is "GStack 2": the surviving items are correctness bugs and consent gaps in upstream's own code that the fork happened to notice, and roughly a third of the shortlist is really "merge a stale community PR the fork carried" (#1509, #2119, #1900, #1777, #1096). The top five by user impact are: (1) ./setup aborting before any skill is registered when Playwright's Chromium download fails (setup:3 `set -e`, setup:806, setup:850 vs skills at setup:1657); (2) the /freeze deny hook reading a different state dir than /freeze writes, so the boundary fails open whenever GSTACK_HOME is set (freeze/bin/check-freeze.sh:35); (3) /ship Step 5/6 still hardcoding `bin/test-lane`, Rails and a Ruby eval runner (ship/sections/tests.md.tmpl:9-113); (4) the question-preference PreToolUse hook keying project preferences by cwd basename while the writer keys by owner-repo, so the "never ask again" enforcement layer never fires for any repo with a remote (hosts/claude/hooks/question-preference-hook.ts:300-306); (5) `transcript_ingest_mode=off` being a documented consent switch that no binary reads (bin/gstack-config:181 is the only occurrence). Safely ignorable: the entire GStack 2 architecture (six dispatchers, fifteen-vector scale system, SHARED-JUDGMENT and VERIFICATION contracts, zero-question budget), the web-data provider stack, PRIVACY.md, issue forms and labels, the multi-OS CI lane, the Node .cjs team hook, and the persona-fleet methodology already parked in TODOS.md.
|
||||
|
||||
## 2. Ranked shortlist — worth taking
|
||||
|
||||
Sorted by panel priority, then user_value_score + fit_score. Every item here either survived refutation (refuted=false) or is a cheap fix whose upstream premise I re-verified by grep at HEAD; the latter are flagged as such and clustered.
|
||||
|
||||
Footnote on the survivor count: the 14 survivors reflect the final (appended) refutation pass. The residual-index.json `prior_refuter` field records an earlier pass with a different boundary on 7 of the 48: s1-prewave-transcript-ingest-mode-off and s1-prewave-terminal-ws-extension-id-pin were refuted=true in the earlier pass and refuted=false in the final one; s1-prewave-setup-install-deadline-and-continue, s1-prewave-readme-update-check-disclosure, s1-prewave-review-loosened-inputs-stale-strings, s6-branches-web-data-official-api-first-rule and s1-prewave-auth-json-file-write-removal had no earlier verdict (prior_refuter is null, not false) and are refuted=true in the final pass. This report follows the final pass throughout.
|
||||
|
||||
**1. /freeze hook state-dir chain (gap2-skillwave-hooks-envelope-and-state-dir-chain-1509), P0, val 8 / fit 10.** The hookSpecificOutput envelope half of #1509 is absorbed, but freeze/bin/check-freeze.sh:35 still reads `STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"` while every writer (freeze/SKILL.md.tmpl:61-64, guard/SKILL.md.tmpl:70-73, unfreeze/SKILL.md.tmpl:31-35, investigate/SKILL.md.tmpl:131-134) resolves through bin/gstack-paths GSTACK_HOME-first. GSTACK_HOME is a documented production knob (bin/gstack-config:16, setup:2135) and the same hook's analytics helper already honours it (careful/bin/hook-extract.sh:71). Result: with GSTACK_HOME set, /freeze prints "boundary set" and the hook hits the no-file allow branch, contradicting its own header ("a boundary that fails open is not a boundary"). Fork refs: bbad1bab (check-freeze.sh hunk), c896016b (test pin); upstream PR #1509 and issue #1459 both OPEN. Port: one line, `STATE_DIR="${GSTACK_HOME:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}"`, mirroring hook-extract.sh; do not port the fork's 12-line probe-and-eval of gstack-paths inside a hook that fires on every Edit. Update test/hook-scripts.test.ts:690-893 to pass `{GSTACK_HOME, CLAUDE_PLUGIN_DATA}` and add the missing regression (GSTACK_HOME dir with freeze-dir.txt, HOME elsewhere, must deny). Credit @NikhileshNanduri, close #1459/#1509. Effort: human 2-3 hours, CC+gstack 15 min. Refuter's strongest counter: only the fork's shape (subprocess in a hot hook) is wrong; the defect itself could not be refuted.
|
||||
|
||||
**2. /ship hardcoded test lanes (s9-skills-ship-hardcoded-test-lanes), P1, val 8 / fit 10.** ship/sections/tests.md.tmpl:9-22 is still a literal bash block that runs `bin/test-lane 2>&1` and `npm run test 2>&1` and warns about `RAILS_ENV=test bin/rails db:migrate`; Step 6 (:44-113) matches `app/services/*_prompt_builder.rb`, runs `EVAL_JUDGE_TIER=full bin/test-lane --eval`, and ships a Haiku/Sonnet/Opus price table; ship/SKILL.md.tmpl:381 hardcodes the `tests`/`vitest` label pair for the evidence check. This is a direct violation of upstream CLAUDE.md "Platform-agnostic design" and survived both port waves because v1.66.1 wrapped the hardcode in evidence labels instead of removing it. Fork refs: 8bc0a04f, d72133e1 (0aca1f77:skills/ship/references/sections/ship/tests.md); upstream issue #1069 and PR #1102 (@JiayuuWang) OPEN. Nothing from the fork transfers (its version is a 2.0 legacy overlay); this is upstream's own deletion-heavy rewrite: Step 5 consumes the command Step 4 (`{{TEST_BOOTSTRAP}}`) resolved from CLAUDE.md or asked and persisted, one evidence label per resolved command; Step 6 conditional on a CLAUDE.md-declared eval command; Step 16 evidence check becomes repeated `--label/--expect-cmd` pairs; same fix at land-and-deploy/sections/readiness-gate.md.tmpl:111-116. Regenerate the three ship goldens; extend test/ship-test-detection-markers.test.ts to reject `bin/test-lane|RAILS_ENV|_prompt_builder|EVAL_JUDGE_TIER` in rendered output. Effort: human ~2 days, CC+gstack 1-1.5 hours. Refuter's counter: TEST_BOOTSTRAP already says "use CLAUDE.md's command"; does not hold because the literal block still runs afterwards regardless.
|
||||
|
||||
**3. Playwright Chromium failure becomes a named warning, not a mid-setup abort (gap3-setup-playwright-best-effort-not-fatal), P1, val 9 / fit 9.** setup:3 `set -e` plus the bare `bunx playwright install chromium` at setup:806 (and the explicit `exit 1` at :850) sit under "# 2." (setup:764), ahead of "# 4. Install for Claude" (:1657), migrations (:1943) and hooks (:1990). An offline, proxied, AppArmor-restricted or Bun-on-Windows box ends with zero skills and a re-run that dies at the same line, even though every later step is already best-effort (emoji font :853-864 is warn-only). Playwright already retries the download five times internally, so what reaches this path is real unavailability, exactly when the 35+ non-browser skills matter most. Issues #1900, #1901, #1902, #913, PR #2233 all OPEN on this behaviour. Fork ref: ce9f0f9a (David Miserak; reachable only from origin/backup/pre-isolated-wave-review-2026-07-14). Port: re-implement on upstream's block, not cherry-pick: wrap the install in `if ! ( ... )` so set -e cannot abort, a `_PW_FAIL_REASON` accumulator (chromium-install | windows-no-node | windows-node-modules | post-install-launch), keep `_PW_LOCK` contention fatal (setup:815-819), keep `_clear_playwright_quarantine` and the EXIT-trap chaining, loud stderr naming the unavailable skills (/qa, /design-review, /browse, make-pdf, /pair-agent) repeated in the final summary, optionally honour GSTACK_SKIP_PLAYWRIGHT=1 (#913). Add the static test the TODOS.md:199-202 entry asks for. Credit @DavidMiserak, close #1900/#1901/#1902/#2233. Effort: human 2-3 hours, CC+gstack 15-20 min plus one free-suite run. Refuter's counter: the fork's message text is false in upstream ordering and its block-wide "no exit 1" test would flag the lock exit; both are port corrections, not reasons to keep the abort.
|
||||
|
||||
**4. gstack-relink ownership guard (gap3-relink-ownership-guard-on-prefix-flip), P1 (refuter says P2), val 8 / fit 9.** bin/gstack-relink:46-53 `_cleanup_skill_entry` does `rm -f` on any symlink and `rm -rf` on any dir with a symlinked SKILL.md with no readlink check, is called on every flat name in prefix mode (:93) and every gstack-* name in flat mode (:99), and setup runs relink as a self-healing step on every install (setup:1679-1685). Upstream already adopted the opposite policy at every sibling site (setup:1040-1057 readlink gate, bin/gstack-uninstall:204-230), so this is the one remaining unguarded deleter of a class fixed three times. Fork refs: 5ed18036 (smblight, upstream PR #2119 MERGEABLE), 4d5f0309. Port: re-implement, gating on the resolved target being inside `$INSTALL_DIR` or `$RENDER_DIR` (relink:43, the fork's `*/gstack/*` pattern misses the render dir and lexically matches non-owned paths); port smblight's two regression tests plus a RENDER_DIR case; decide the flat-mode collision policy at relink:104 (loud skip rather than silent overwrite). Credit @smblight. Effort: human ~2 hours, CC+gstack 15-20 min. Refuter's counter: blast radius is narrower than the panel implied (only prefix-mode users with foreign symlinked skills sharing a name, and only the registration link is lost); accepted, hence P2, but the bug stands.
|
||||
|
||||
**5. Merge two stale community PRs the fork carried: taste direction-aware confidence (s8-codediff-taste-direction-aware-confidence, val 6 / fit 10) and setup-deploy Render key bytes (s1-prewave-setup-deploy-render-key-bytes, val 6 / fit 10), both P1 (refuter: P2/P3 housekeeping).** bin/gstack-taste-update:199-201 computes `confidence = approved_count/(total+1)` for both buckets, so a rejected preference is pinned at 0 forever; cmdShow's rejected sort at :243 is a no-op and the drift warning at :204 is unreachable; upstream's own test hand-seeds confidence 0.8 to dodge it (test/taste-engine.test.ts:269-270). Fork 944dcab2 is a strict subset of upstream PR #1777 (@jbetala7, MERGEABLE). setup-deploy/SKILL.md.tmpl:104 still says `echo $RENDER_API_KEY | head -c 4`; fork fc8dded2 is byte-identical to upstream PR #1096 (@JiayuuWang, MERGEABLE). Port: merge #1777 (rebase, line moved 196 to 201) and #1096 as-is, regen SKILL.md, close #1776/#1078. Skip the fork's executable snippet test and tree-wide regex (false positives on test/evidence.test.ts:117). Effort: human 1 hour total, CC+gstack 15 min. Refuter's counter: nothing here is from the fork and severity is overstated (Render keys carry a fixed prefix, unverified); true, but both fixes are correct and free.
|
||||
|
||||
**6. make-pdf preview fires remote image GETs despite the offline promise (s8-codediff-make-pdf-preview-offline-gate), P1, val 6 / fit 10.** make-pdf/src/orchestrator.ts:361-362 writes `rendered.html` verbatim to the preview path; `opts.allowNetwork` is read only on the generate path (:170); make-pdf/src/cli.ts:197 parses `--allow-network` into PreviewOptions where it is dead; render.ts:256-258 deliberately leaves remote `<img src>` for the inliner that preview skips by design (:331-345). So `$P preview` beacons once per hotlinked image with no flag passed, and shows images the PDF will not contain. Fork ref: 8fae162d (~30 lines, reuses IMG_TAG_RE/SRC_RE and buildBlockedRemotePlaceholder in diagram-prepass.ts, unit test included). Port: cherry-pick, keep upstream's `browseClient.PAYLOAD_TMP_DIR` path, export blockRemoteImages, gate in preview() unless `allowNetwork === true`, add an orchestrator-level assertion. No egress receipt needed: it removes a fetch. Effort: human 2 hours, CC+gstack 10 min. Refuter's counter: "the user's browser fetching the user's own images is not gstack egress" is answered by the documented promise and the dead flag.
|
||||
|
||||
**7. browse click on a missing selector waits twice (s8-codediff-browse-click-missing-selector-no-second-wait), P1, val 7 / fit 9.** browse/src/write-commands.ts:349-369: after `.click({ timeout: 5000 })` throws, the catch calls `locator.evaluate(el => el.tagName === 'OPTION')` on the same locator; Playwright auto-waits, browse sets no default timeout (grep setDefaultTimeout in browse/src = 0), so the 30s library default collides with the CLI's 30s abort at browse/src/cli.ts:828 and the user sees "Command timed out" instead of the precise selector error at ~5s. Fork ref: a84a6e23 (~12 lines: `optionLocator.count().catch(() => 0)`, evaluate only when count === 1, plus a regression test). Port: cherry-pick only that hunk and test; bump the test's own timeout to 10s; reproduce pre-fix timing once with Playwright installed for the CHANGELOG line. Effort: human 2 hours, CC+gstack 10 min. Refuter found no counter beyond CI timing tightness.
|
||||
|
||||
**8. ios-qa proxy hangs forever on a suspended app (s8-codediff-iosqa-proxy-timeout-hardening), P1, val 7 / fit 9.** ios-qa/daemon/src/proxy.ts:57-84 passes `timeout: 30_000` to httpRequest but per Node docs the 'timeout' event only notifies; the request must be destroyed manually. No outer deadline exists (grep setTimeout|headersTimeout in ios-qa/daemon/src/index.ts = none), so shouldRefreshTunnel's 504 branch (index.ts:245-256) is unreachable and /ios-qa, /ios-fix wedge with no typed error. Upstream has zero behavioural tests of proxyToDevice. Fork ref: 2c487305. Port: adapt, do not cherry-pick: add the `settled` guard, `req.on('timeout')` to 504 upstream_timeout then `req.destroy()`, `res.on('aborted')` to 503, `opts.timeoutMs`; KEEP `reject(err)` for unmapped codes so index.ts:213-243's ECONNRESET/EPIPE to 503 device_disconnected re-bootstrap still fires; leave out the bundled `x-gstack-expected-bundle-id` header. Port the bounded-504 test. Effort: human ~2 hours, CC+gstack 15 min. Refuter's counter: a verbatim port would defeat upstream's recovery mapping; adaptation resolves it.
|
||||
|
||||
**9. `transcript_ingest_mode=off` is a dead consent switch (s1-prewave-transcript-ingest-mode-off, clustered with residual (d) of s8-codediff-bin-lib-absorbed-batch), P1, val 7 / fit 9.** bin/gstack-config:181 (`transcript_ingest_mode) echo "off"`) is the only non-prose occurrence in the repo; bin/gstack-memory-ingest.ts:541-545 gates transcripts solely on `--sources` (default ALL_TYPES at :234) and bin/gstack-gbrain-sync.ts:1211-1265 never reads the key. Yet setup-gbrain/memory.md:138 advertises it as "Disable entirely" and setup-gbrain/sections/transcript-gate.md.tmpl:47-53 offers "E) Never ingest transcripts (off)" and then runs `gbrain-sync --full` unconditionally, so a user who declined gets every transcript ingested on every /sync-gbrain. Fork refs: 6249074d (PR #2232 slice, /tmp/ta-wt/memory-trust:bin/gstack-memory-ingest.ts:523-538), fork PR #7 commit 7260b8e2 (CLOSED; not in the local clone, verified via `gh pr diff 7`; exempts --probe). Port: natively, not cherry-pick: decide semantics (honour only an explicit `off`; either change the :181 default to empty and update test/gstack-config-defaults.test.ts:139, or read the raw key with a comment explaining why); pass memory-ingest's existing `--sources` minus transcript from gbrain-sync when off; exempt --probe; one stderr line when the skip fires; fix transcript-gate.md.tmpl:52 so option E does not run --full; port the "honors off mode before invoking gbrain" test. Effort: human ~1 day, CC+gstack 30-45 min. Refuter's counter: the fork regex-parses config.yaml directly, bypassing gstack-config's GSTACK_STATE_ROOT resolution, and PR #2232 is CONFLICTING; both are shape objections, and this slice is independently shippable because it only removes writes.
|
||||
|
||||
**10. question-preference hook slug mismatch (s2-runtime-question-pref-hook-slug-mismatch), P1, val 8 / fit 8.** hosts/claude/hooks/question-preference-hook.ts:300-306 `slugFromCwd` returns `path.basename(cwd)`, fed at :395 to `lookupPreference` (:149-159) which reads `projects/<slug>/question-preferences.json`; the only writer, bin/gstack-question-preference:36-38, buckets by `gstack-slug` which is `owner-repo` for any repo with an origin remote (bin/gstack-slug:17-20). Additionally, no writer of `global-question-preferences` exists anywhere (only the hook reads it and the test seeds it), so the Layer 2 deny is effectively inert outside the no-.git test fixture. Fork ref: b6572ebb (mechanism is GStack-2 runtime/identity.js, not portable). Port: replace slugFromCwd's body with `slugFromEnvironment(stateRoot(), stdin.cwd)` from lib/bin-context.ts:150 (git-spawn-free on cache hits, writes the same slug-cache as gstack-slug), keep `'unknown'` for missing cwd; add a fixture that pre-seeds the slug-cache and asserts a deny; measure first-miss cost inside the 3s hook budget; confirm the Windows lane. Effort: human ~4 hours, CC+gstack 20 min. Refuter's counter: Layer 1 (`gstack-question-preference --check`) already honours project prefs so users mostly see correct behaviour; does not excuse a documented enforcement layer that never fires.
|
||||
|
||||
**11. Chromium dead on Ubuntu 24.04 default kernel (s8-codediff-browse-userns-sandbox-probe), P1, val 8 / fit 8.** browse/src/browser-manager.ts:88-99 `shouldEnableChromiumSandbox` checks only win32, GSTACK_CHROMIUM_NO_SANDBOX, CI, CONTAINER and root; repo-wide grep for apparmor|userns hits only xprotect-heal.ts NEGATIVE_SIGNATURES; #2157 is OPEN and describes the Ubuntu 24.04 LTS default (`apparmor_restrict_unprivileged_userns=1`). Fork ref: 4a8833cc (sysctl probe, `isNoUsableSandboxError`, relaunch-once, `warnSandboxUnavailableOnce`, 8 unit tests). Port: take the relaunch-once fallback first, composed inside the doLaunch closure at all three sites (browser-manager.ts:528, :744, :1821) so launchWithXProtectHeal stays the outer layer; emit SANDBOX_UNAVAILABLE once and surface it in `browse status`; append the GSTACK_CHROMIUM_NO_SANDBOX=1 hint to the startup-error text; treat the sysctl probe as optional (it saves one failed launch but can downgrade where a setuid helper would have worked). Port the unit tests; extend the static tripwire to all three launch sites. Cite #2157 as closing; #2101 is not fixed by this. Effort: human 1 day incl. an Ubuntu 24.04 VM check, CC+gstack 30-45 min. Refuter's counters: the fork author asked to close #2157 as resolved by the env var; upstream deliberately chose opt-in (CHANGELOG.md:3960); the fork's warning goes to the daemon log. None outweighs a dead daemon on the current LTS default, and a relaunch on the literal zygote fatal has zero false positives.
|
||||
|
||||
**12. Terminal-agent WS Origin pin is never armed (s1-prewave-terminal-ws-extension-id-pin), P1, val 6 / fit 9.** browse/src/terminal-agent.ts:41 `const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''`, :602 gated compare; browse/src/terminal-agent-control.ts:77-79 spawns the agent with only BROWSE_STATE_FILE/BROWSE_SERVER_PORT/BROWSE_OWNER_PID, so production never sets it, while the comment at :583-584 says the Origin gate is "required, not optional". Fork refs: 7b3f391b, d947d2e1 (PR #2226). Port ONLY the pin: split GSTACK_EXTENSION_ID into a small browse/src/extension-identity.ts (new file) re-exported from server.ts, make the /ws Origin compare unconditional (403), update the fake-origin test sites (terminal-agent-integration.test.ts:120-135) and add a foreign-ID case; before merge confirm GStack Browser.app / GBrowser's baked extension carries the same manifest key or gate the strict compare behind isCustomExtensionBakedChromium(); never copy the fork constant (`hjcdll...` is NOT upstream's `dgbkdb...`, server.ts:306). Effort: human 2 hours, CC+gstack 15 min. Refuter's counter: defense in depth only, since AUTH_TOKEN is already released only to the pinned origin via POST /extension-token (server.ts:1886-1897); accepted, which is why this is last, but the code comment and the code disagree and the fix is two lines.
|
||||
|
||||
**13. Cluster: ten small bin/browse/template fixes (grep-verified at HEAD, panel TAKE P1, not adversarially refuted).** Each is a few lines with the upstream defect re-confirmed by me at the cited line; none carries voice or design risk.
|
||||
- `gh pr checks` field names (gap2-skillwave-land-gh-checks-targeturl-bugfix, val 6 / fit 10): land-and-deploy/sections/first-run-validation.md.tmpl:112 `--json name,targetUrl` and land-and-deploy/SKILL.md.tmpl:154 `--json name,state,status,conclusion`; the panel verified live with gh 2.83.2 that both field sets are rejected ("Unknown JSON field"). Fix: `name,link` and `name,state,bucket`, regen. Fork ref: bbad1bab.
|
||||
- Codex/Factory /ship skips plan verification (gap5-hygiene-codex-ship-qa-only-path, val 6 / fit 9): scripts/resolvers/review.ts:1178 `cat ${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md`; hosts/codex.ts has no qa-only pathRewrite (grep = 0). Fix: pathRewrite to `$GSTACK_ROOT/../gstack-qa-only/SKILL.md` for codex and factory, regen goldens, test. Upstream PR #1772 (@spacegeologist) OPEN since 2026-05-28.
|
||||
- Detached HEAD emits `BRANCH=HEAD` (s8-codediff-slug-detached-head-branch, val 6 / fit 10): bin/gstack-slug:274-276 passes `git rev-parse --abbrev-ref HEAD` through, so CI runs and bisects pool review/evidence logs under HEAD-reviews.jsonl. One-line map to `unknown`, plus a test. Fork ref: b6572ebb.
|
||||
- brain-cache hardcodes `~/.claude/skills/gstack/bin/gstack-config` (s8-codediff-brain-cache-gstack-config-hardcoded-path, val 6 / fit 10): bin/gstack-brain-cache:614-616 silently falls back to defaults on every non-Claude host. Fix: `join(import.meta.dir, 'gstack-config')`.
|
||||
- gstack-config ignores inline `# comments` (gap3-config-inline-comment-stripping, val 6 / fit 9): bin/gstack-config:321-329 read_config_value has no `#` strip, so `update_check: false # temp` fails to disable; browse/src/config.ts:208-229 (the "single source of truth" TS reader) does strip. Fix: one sed clause ordered before the key strip; two tests.
|
||||
- welcome.html loads Google Fonts and Fontshare (s8-codediff-browse-welcome-offline-fonts, val 6 / fit 9): browse/src/welcome.html:7-8; server auto-navigates the headed browser there on launch, unreceipted. Fix: system font stack (+9/-7) and a no-remote-asset tripwire. Fork ref: e1cd3096.
|
||||
- Dangling symlink passes validateOutputPath (s8-codediff-browse-path-security-dangling-symlink, val 6 / fit 9): browse/src/path-security.ts:43-54 shares one try for lstatSync and realpathSync with `if (e.code !== 'ENOENT') throw e`, so a dangling link under /tmp pointing outside falls through to the parent-dir check. Fork ref: f14445bb (restructure + test).
|
||||
- v1.27 artifacts rename uses bare repo names (gap3-migration-v127-owner-qualified-gh-rename-and-heal, val 8 / fit 8): gstack-upgrade/migrations/v1.27.0.0.sh:219-224 `gh repo rename NEW --repo OLD` with stderr discarded; gh requires OWNER/REPO so the GitHub-side rename fails every time. Take the owner-qualification hunk (fork 5c5b8c4e, Brandon Pugsley) and consider the heal migration (56425c9a); keep upstream's #1383 failure semantics.
|
||||
- GSTACK_HOME not honoured as state root in four bins (s8-codediff-gstack-home-state-dir-consistency, val 5 / fit 10): bin/gstack-update-check:25 and bin/gstack-telemetry-log:27 read only GSTACK_STATE_DIR; bin/gstack-detach writes `~/.gstack/locks` and its test cleans the real home (test/gstack-detach.test.ts:141). Six one-line edits.
|
||||
- buildGbrainEnv passes a project's DATABASE_URL into a PGLite gbrain (s8-codediff-gbrain-exec-strip-caller-database-url, val 7 / fit 8): lib/gbrain-exec.ts:117-125 returns the caller env untouched when config has no database_url; issue #1917 OPEN. Adapt (strip only when config exists and lacks database_url; keep GSTACK_RESPECT_ENV_DATABASE_URL), flip the three pinning tests.
|
||||
Effort for the cluster: human ~2 days, CC+gstack ~1.5 hours across two or three bisected PRs.
|
||||
|
||||
## 3. Contested — take only if the maintainer agrees
|
||||
|
||||
- **Self-contained questions (s3-judgment-self-contained-questions).** Refuted as a universal preamble rule (doubles output tokens at every gate; hardcodes host UI collapsing behaviour); the office-hours-specific failure in issue #879 is real. Tie-break: add one sentence at office-hours/sections/design-and-handoff.md.tmpl:168 printing the saved path plus the doc body as assistant text before the Approve/Revise gate (credit @RyanAlberts, close #879/#1116), and nothing universal?
|
||||
- **/review small-diff coverage (s9-skills-review-small-diff-sweep-hole).** Refuted as a Pass 3 sweep (upstream runs an adversarial subagent on every diff, review.ts:509); what remains is one inaccurate sentence at review/checklist.md:10 and no Access Control bullet in Pass 1 CRITICAL. Tie-break: is an Access Control bullet (missing auth middleware, IDOR, path traversal) worth its bytes in a forced-read file under the context-budget ratchet?
|
||||
- **Reply in the user's language (s9-skills-match-user-language, #679).** Refuted as an always-on Voice-section line (community-PR guardrail, chat-only scoping leaves reports English). Tie-break: an opt-in `reply_language` config key rendered into Writing Style, with the mixed-language tests time-attack asked for on #679?
|
||||
- **AskUserQuestion compact payload (gap2-skillwave-askuserquestion-compact-payload-1241).** Refuted: shrinking `question` to 80 chars strips what question-log-hook and question-preference-hook hash and classify (hosts/claude/hooks/question-log-hook.ts:288-313, question-preference-hook.ts:404-457) and re-legitimises prose-then-tool. Tie-break: reproduce #1208 on the VSCode host first; if real, decide payload-vs-prose as a deliberate AUQ resolver design with an A/B receipt.
|
||||
- **Catalog trim on external hosts (gap3-catalog-trim-external-hosts, #1972).** Refuted: on Codex the description is the only routing signal and the fork's own maintainer refused this on #1972 without routing evals. Tie-break: run a Codex routing eval; if it passes, merge #1972 with @hadrien-de-march credit.
|
||||
- **$B record (s6-branches-browse-record-cdp-screencast, fork PR #54).** Refuted as a drop-in: `record` in SCOPE_READ, spawnSync ffmpeg in the daemon request path, frames never cleaned, cap evaluated only on frame arrival, VP9 into .mp4, and #2497 is already queued at TODOS.md:43. Tie-break: CDP screencast (no context rebuild, works headed, no-ffmpeg fallback) versus #2497's recordVideo .webm; decide once at triage and land one.
|
||||
- **Codex sandbox canary (s8-codediff-codex-sandbox-canary), panel TAKE_PARTIAL P1, val 8 / fit 7, xref PARTIAL, not among the 48 refuted.** Fork ref 8abb08e5 (bin/gstack-codex-probe:110-142 in the fork tree). Upstream has a real round-trip model probe (bin/gstack-codex-probe:39-142) and typed CODEX_MODE degradation (scripts/resolvers/constants.ts:118-160) but no sandbox/userns signature (grep bwrap|bubblewrap|landlock|user namespace across bin/ lib/ scripts/ codex/ review/ ship/ = 0), so a Codex that exits empty because bwrap cannot create user namespaces reads as a clean pass in /review, /ship and /autoplan on devcontainers, Flatpak and hardened Ubuntu. Issue #1892 is OPEN. The appended refuter for s8-codediff-bin-lib-absorbed-batch directs this to be handled under #1892 on its own merits, not as a fork port. Tie-break: does Garry want a fail-closed `sandbox_unavailable` CODEX_MODE with the mandated one-liner "codex skipped: sandbox unavailable" plus the same signature in review.ts:383-385/548-550/689-691 and codex/SKILL.md.tmpl:310, or the GSTACK_CODEX_NO_SANDBOX opt-in that #1892 asks for, or both? Effort: human ~1 day, CC+gstack ~45 min (+15 min for the env gate).
|
||||
- **Privacy page (s7-docs-method-privacy-data-flow-doc).** Refuted as a fork port (Context.dev-flavoured, drifts). The real defect surfaced: `gstack-egress grants` enumerates four grants (bin/gstack-egress:155-190) while bin/gstack-config defines `update_check`, `codex_reviews`, `pair_agent`. Tie-break: fix the grants under-enumeration (upstream bug, no fork lineage) and stop there, or also write a short table in README's Privacy section?
|
||||
- **Codex dispatch consent and scan-at-sink (s4-fleet-codex-consent-and-redact-at-sink).** Refuted: Codex runs locally with `-s read-only` in the repo and reads any file, so scanning a materialised diff is scan-then-re-render, and `codex_reviews` is already the master switch. Tie-break: an egress receipt (fail-open) at each codex dispatch, since test/egress-receipt-wiring.test.ts:138-143 exempts skill prose, plus one disclosure line in the preflight?
|
||||
- **Untrusted-data authority rule (gap6-s15-hostadv-untrusted-data-authority-rule).** Refuted as a preamble generator (generate-evidence-directive.ts:3-12 records the decision not to import SHARED-JUDGMENT). Concrete gap: qa/qa-only use `{{BROWSE_SETUP}}` which does not carry UNTRUSTED_CONTENT_WARNING (scripts/resolvers/browse.ts:58-60). Tie-break: wire the existing warning into BROWSE_SETUP and take the two per-skill lines from PR #2186 (@danlayfield), nothing else?
|
||||
- **Delete ~/.gstack/.auth.json writer (s1-prewave-auth-json-file-write-removal).** Refuted for now: browser-manager.ts:638-640 says the component-baked GBrowser extension reads it, and the token is already on disk 0600 in the browse state file. Tie-break: does Garry know whether GBrowser's baked extension bootstraps via POST /extension-token? If yes, delete plus a migration.
|
||||
- **Loosened inputs and stale strings (s1-prewave-review-loosened-inputs-stale-strings).** Byte-for-byte upstream PR #2141 (@aversini, MERGEABLE), not a fork item. Tie-break: does Garry want two more anecdote-derived categories in a forced-read checklist? If yes, merge #2141 keeping the "Enum & Value Completeness" heading (test pin at test/skill-validation.test.ts:893-897).
|
||||
- **Base detection for local-only repos (s9-skills-base-detection-local-only).** Refuted: the fork ladder adds `git remote set-head -a` (network) and a merge-base guess, and upstream pins "fall back to main" (test/gen-skill-docs.test.ts:649-651). Two literal-`main` sites bypassing BASE_BRANCH_DETECT are upstream bugs: scripts/resolvers/utility.ts:111-112 and design.ts:83. Tie-break: fix those two now; revisit a "no remote" terminal state only on field reports.
|
||||
- **"Pre-existing" requires evidence (s9-skills-pre-existing-requires-base-run).** Refuted as an automatic base-branch run (deps, DB side effects, minutes per ship). Tie-break: rewrite T1 vocabulary to verified/unverified with base-branch CI status (`gh run list --branch <base>`) as default evidence and a failing-files-only worktree run as an opt-in option?
|
||||
- **CLAUDE.md `## Review` section (s9-skills-review-claudemd-review-section, #452).** Refuted: the host already loads CLAUDE.md, and free-form suppression rules cannot be told from tightening. Tie-break: a docs/skills.md paragraph documenting the convention (credit @evb87-tech) and close #452?
|
||||
- **/scrape evidence gate (gap2-skillwave-scrape-evidence-gate-1717).** Refuted as a 40-line step (coached E2E, breaks skillify's final-attempt slice). Tie-break: two sentences in Step 4 ("if the intent names a filter, count or ordering, the rows must demonstrate it"), credit @orendi84, close #1717?
|
||||
- **gen-skill-docs single-host failure exits 0 (s2-runtime-gen-skill-docs-single-host-fatal).** Refuted as P1: setup:727-731 renders codex unconditionally under set -e, so flipping :1150 would let a Codex render error abort the Claude install. Tie-break: P3, only with a setup decision to warn-and-continue for unselected hosts and a throwing-template test.
|
||||
|
||||
## 4. Defer
|
||||
|
||||
- **Codex prompt over stdin (s7-docs-method-regression-pr2370-stdin-prompt-dispatch; also s4-fleet-codex-mktemp-portable's residual).** Blocked on a real bug: bin/gstack-codex-probe:194 runs `"$@" &` in the no-gtimeout watchdog, and bash redirects an async command's stdin from /dev/null, so `codex exec - < file` would dispatch an empty prompt on stock macOS. Unblocks: change :194 to `"$@" <&0 &` with a PATH-masked test, then convert exactly review-mode.md.tmpl:87 and review.ts:373.
|
||||
- **Time constraint as a ceiling (s3-judgment-time-constraint-caps-scale).** Contradicts autoplan/SKILL.md.tmpl:150-159 "MUST NOT compress"; docs/designs/PACING_UPDATES_V0.md:97-110 already owns chain-scoped accounting. Unblocks: fold "a time box caps question rounds, never review completeness" into PACING V1.1.
|
||||
- **Transcript source-id isolation and current-repo scoping (s1-prewave-transcript-source-id-isolation, s1-prewave-transcript-current-repo-scope; PR #2232 remainder, issue #2140).** Fork passes `--source-id` to `gbrain import` with no support probe (gbrain docs spell it `--source`); two-pass split must be re-derived across resume, reconciliation (bin/gstack-memory-ingest.ts:1803-1812, :2316-2330) and BulkResult merging. Unblocks: the never-double-store review TODOS.md:3947 gates on, a flag probe in the #2662 pattern, and confirmation that gbrain can exclude a source from dream; sequence after the `off` fix.
|
||||
- **Defensive framing for red-team and security specialists (gap2-skillwave-review-defensive-framing-shared-resolver-1921).** No reproducible denial trace exists for those two prompts (time-attack's own #1921 review asked for one). Unblocks: an E2E trace; then frame only the red-team prompt via a shared constant.
|
||||
- **Recording caps/fallbacks, QA recording prose, shutdown flush and tunnel-denial pins (s6-branches-record-caps-and-fallbacks, s6-branches-qa-recording-specialist-judgment, s6-branches-record-shutdown-flush, s6-branches-record-tunnel-denial-and-scope).** Nothing to govern until a record primitive lands. The fourth item (panel TAKE_PARTIAL P1, val 4 / fit 9, fork ref 76c89d56) is two one-line negative pins in browse/test/dual-listener.test.ts:109-120 and browse/test/tunnel-gate-unit.test.ts:41-53 plus the scope decision; upstream's closed TUNNEL_COMMANDS allowlist (browse/src/server.ts:315-344) already denies any unlisted command, so the pins are documentation of a property, not a behaviour change, and its earlier refuter said exactly that. Unblocks: the #2497/#54 decision; then wall-clock cap, byte ceiling, zero-frame screenshot, .webm for VP9, the tunnel pins with the "a continuous capture is a larger exfil surface than a single screenshot" rationale, the scope classification (panel recommends SCOPE_WRITE over the fork's SCOPE_READ or #2497's SCOPE_CONTROL), and four neutral prose rules written against the real semantics.
|
||||
- **Web-data task taxonomy (s6-branches-web-data-task-taxonomy-and-rankings).** Points at providers upstream does not ship; the authenticated-to-own-browser rule already lives in scripts/resolvers/third-party-actions.ts:35-37. Unblocks: the Aside Phase 2 product call at TODOS.md:462-486; then two sentences in scrape's "does NOT do" list.
|
||||
- **ios-qa local package wording (gap5-hygiene-ios-qa-local-package-wording-1735).** Not a fork item; an upstream #1735 close-out: ios-qa/SKILL.md.tmpl:124/:255 and ios-clean/SKILL.md.tmpl:82's wrong `.package(url:)` line. Unblocks: whoever closes #1735; decide bearer persistence separately against #1796.
|
||||
- **make-pdf entity-obfuscated `javascript:` href (gap3-makepdf-parser-backed-sanitizer-not-recommended, skeptic flip, never panel-judged).** Skeptic reports make-pdf/src/render.ts:243-246 rewrites only a literal `javascript:` and fork PR #6 commit f1a92355 (CLOSED; not in the local clone, verified via `gh pr diff 6`) has a test for `javascript:`. Exploitability is low (output is a PDF rendered by Chromium), so not a shortlist item. Unblocks: port that one test; if it fails, extend the existing decode step, not the parser-backed sanitizer.
|
||||
- **ios-qa per-mutation bundle guard (s8-codediff-iosqa-active-bundle-assertion, skeptic flip).** Needs StateServer.swift.template changes; decide with the ios-qa maintainers alongside #1796/#1975.
|
||||
- **Panel DEFERs:** parallel /batch across tab groups (s6-branches-browser-batch-multitab-plan; blocked on the global switchTab pin at browse/src/server.ts:1063-1072); fast-path gate E2E (s4-fleet-fast-path-gate-e2e; presupposes a trivial-change fast path upstream has not adopted); Windows console-flash residue (gap1-s10-browsewave-windows-console-flash-suite; #1784's claim that Bun.spawnSync ignores windowsHide is unverified, needs a Windows tester); iOS XCUITest evidence schema and flow runner (gap4-s13-ios-xcuitest-evidence-schema, gap4-s13-ios-xcuitest-flow-runner; contradict ios-qa/SKILL.md.tmpl:41 "No simulator, no XCTest", needs an architecture decision); /cso --fix (gap2-skillwave-cso-fix-mode-safe-autofixes-1053; inverts cso's report-only contract at cso/SKILL.md.tmpl:44, needs a safe redesign); Windows network lockdown CI (s6-branches-windows-network-lockdown-ci; no offline CI phase exists to use it).
|
||||
|
||||
## 5. Skip
|
||||
|
||||
| Item | Reason |
|
||||
|---|---|
|
||||
| Bound Playwright install + continue (s1-prewave-setup-install-deadline-and-continue) | Duplicate of gap3 wearing a 300s deadline that would kill legitimate slow downloads; Playwright already retries 5x. Close #2233 once gap3 lands. |
|
||||
| Setup probe deadline (s1-prewave-setup-probe-deadline) | Headline absorbed (setup:417-466 `_kill_tree`/`_wait_with_deadline`, #2136 CLOSED); install bound better served by upstream PR #2359. |
|
||||
| Official-API-first rule (s6-branches-web-data-official-api-first-rule) | Truism already in the tier-3 reuse ladder; motivated by one planning anecdote; targets /scrape, which is one-shot by contract. |
|
||||
| PR mutations via REST (s9-skills-pr-mutations-rest) | Ship Step 19 rewrites the body with the #1079 REST fallback after document-release; #1079 CLOSED; standalone exposure is warn-and-continue. |
|
||||
| Team hook as Node .cjs (s1-prewave-team-hook-cross-platform-cjs, #2229) | Claude Code on Windows requires Git Bash; the real fix is the `bash ` prefix convention (setup:2060-2071) on bin/gstack-team-init:183-189; fork re-introduces the #2500 false-block and a Node dependency. |
|
||||
| Matcher `Skill\|skill` (s1-prewave-team-hook-matcher-lowercase-skill) | No-op on every supported host; Copilot CLI does not read .claude/settings.json. |
|
||||
| Issue forms and labels (s7-docs-method-issue-templates-and-labels) | Fork never implemented them either; upstream triage is wave-based, not label-based. Do fix the dangling "The evidence bar" link at .github/PULL_REQUEST_TEMPLATE.md:4 (grep 'evidence bar' CONTRIBUTING.md = 0); that fix is record s7-docs-method-contributing-evidence-bar-section, see section 5a. |
|
||||
| Multi-OS static tree lane (s7-docs-method-multi-os-static-tree-gate) | Static budget tests have zero platform dependence; a macOS lane is an upstream CI decision (mktemp/keychain files), not a fork import. |
|
||||
| README update-check disclosure (s1-prewave-readme-update-check-disclosure) | Fork line is now inaccurate (ls-remote plus raw fetch, two cache windows); rewrite natively from bin/gstack-update-check:157-243 if wanted. |
|
||||
| Hardcoded `origin/main` residue (s4-fleet-hardcoded-origin-main-residue) | Three descriptive prose sites with near-zero impact (review/checklist.md:5, review/greptile-triage.md:86, office-hours/SKILL.md.tmpl:87); fix as housekeeping when touching those files, do not adopt the fork's banned-regex tripwire. |
|
||||
| /plan-pm-review (gap2-skillwave-plan-pm-review-1666) | Explicitly declined by Garry on #1412 ("The user IS the PM"). |
|
||||
| Conductor implicit AUQ hook opt-in removal (s1-prewave-conductor-implicit-auq-hook-optin) | Upstream deliberately narrowed rather than removed it (setup:2294-2307) and pins the contract in test/setup-plan-tune-hooks-noninteractive.test.ts:91-95. |
|
||||
| QA evidence-per-finding layout (gap5-hygiene-1484-qa-evidence-per-finding) | Panel SKIP: judgment already upstream; #1484 folder layout adds nothing. |
|
||||
| patchright stealth backend (gap1-s10-browsewave-patchright-stealth-backend) | Panel SKIP: ongoing per-Playwright-bump cost for a CDP leak upstream's Layer C already addresses. |
|
||||
| BACKLOG-MAP generator (s7-docs-method-backlog-map-triage-generator) | Panel SKIP: 810-line offline triage generator for a fork process. |
|
||||
| browse-migrate legacy extension IDs (s1-prewave-browse-migrate-legacy-extension) | Panel SKIP: path-derived legacy IDs are not an upstream state. |
|
||||
| CLAUDE.md `node -e` snippet (s1-prewave-team-claude-md-node-snippet) | Panel SKIP: same Node dependency argument as #2229. |
|
||||
| /setup-search-mcp with Exa (gap2-skillwave-setup-search-mcp-exa-2168) | Panel SKIP: adds a third-party MCP endpoint to Search Before Building. |
|
||||
| GStack 2 judgment contracts: verification contract, total-verification ledger, execution depth profiles, build-scale classification, zero-question budget, proportional planning, trivial-change fast path, one-inspection-command-per-call, dispatcher precedence (s9-skills-*, s3-judgment-*) | Depend on the six-dispatcher architecture and the fifteen-vector scale system; PACING_UPDATES_V0.md:97-110 already recorded what to fold in. |
|
||||
| Fleet harness sub-ideas: tEnd bookend, rage taxonomy, abandonment contract, metric provenance (s4-fleet-*) | Methodology for the persona-fleet harness TODOS.md already defers. |
|
||||
| Docs/method artifacts: ADR series, audit-backlog ledger, bloat ledger, host compat evidence tiers, routing scenario fixtures, install-matrix artifacts, skills.sh distribution, devcontainer, upgrade/rollback doc, egress audit doc (s7-docs-method-*) | Contributor-facing process artifacts in fork voice; upstream keeps the equivalents machine-readable (receipts, ratchet fixtures). |
|
||||
| New skills: /yc-review, /diagnose, /fanout, /community-review, /land merge queue, /plan-status, /pr-prep audit (s6-branches-yc-review-skill, gap2-skillwave-*) | Each adds catalog bytes under the enforced 1,150-token discovery ceiling for a niche. #1815 is Garry's own still-OPEN /land proposal (gh: OPEN, author garrytan) and should be decided there, not via the fork; #1949 (/fanout) is a CLOSED community PR by @sohmn. |
|
||||
| Host adapters: Grok Build, Pi/Antigravity/Vibe/Qoder, Codex ~/.agents migration, request_user_input rewrite, native image_gen (gap3-hosts-*) | Host-config refactor is blocked (docs/designs/SLATE_HOST.md); declarative configs alone ship untested surfaces. |
|
||||
|
||||
Beyond the items above, 39 panel-P1 and roughly 130 P2/P3 take or take-partial items (170 P2/P3 in total: P2 126, P3 44 per summary.json) were neither adversarially refuted nor grep-verified for this report. The P2/P3 leftover (browse env knobs, retro/health additions, ios-qa docs, test-infra pins, Windows PowerShell bootstrap, statusline, learnings refine, design round artifacts) is individually small and can be picked up opportunistically from /tmp/ta-eval/residual-index.json. The 39 P1 items are listed next so they are not mistaken for P2/P3.
|
||||
|
||||
### 5a. Unrefuted panel-P1 take / take-partial items (39)
|
||||
|
||||
Scores are user_value / fit from residual-index.json. "Panel only" means no evidence beyond the panel record was gathered for this report; the panel was generous, so treat these verdicts as unproven until grep-verified or refuted. Two entries carry an earlier prior_refuter refuted=true and are marked.
|
||||
|
||||
| Id | Panel | Val/Fit | Xref | Disposition |
|
||||
|---|---|---|---|---|
|
||||
| s8-codediff-browse-absorbed-batch | TAKE_PARTIAL | 7/9 | PARTIAL | Mis-titled batch; 12 of 13 absorbed. Panel only for the one residual. |
|
||||
| s9-skills-ship-breaking-change-over-linecount | TAKE_PARTIAL | 7/9 | PARTIAL | Overlaps CLAUDE.md "Scale-aware bumps" prose; panel only. |
|
||||
| s9-skills-eng-review-pre-mortem-and-data-model | TAKE_PARTIAL | 8/8 | MISSING | Adds a pre-mortem step to plan-eng-review; voice-bearing template change, needs Garry's read. Panel only. |
|
||||
| gap3-codex-probe-custom-provider-env-key | TAKE | 7/9 | MISSING | Codex auth probe for config.toml env_key providers; panel only. |
|
||||
| gap2-skillwave-office-hours-codebase-surface-premise-verify-1738 | TAKE_PARTIAL | 8/8 | MISSING | Verify codebase-fact premises before AUQ in office-hours; template change, panel only. |
|
||||
| gap2-skillwave-retro-persist-recommendations-followthrough-1834 | TAKE | 8/8 | MISSING | /retro persists recommendations and scores follow-through; panel only. |
|
||||
| gap2-skillwave-retro-language-agnostic-test-detection-2037-2013 | TAKE | 8/8 | PARTIAL | Python/Terraform/Bats test-file detection in retro metrics; consistent with platform-agnostic rule, panel only. |
|
||||
| gap2-skillwave-cso-mini-shai-hulud-tier3-1523 | TAKE | 8/8 | MISSING | /cso Tier 3 known-campaign IOC rules; panel only. |
|
||||
| gap6-s15-hostadv-fixture-set-and-schema | TAKE | 8/8 | MISSING | Injection-resistance fixture set; sibling of the persona-fleet harness TODOS.md defers, panel only. |
|
||||
| s8-codediff-iosqa-drop-resolve6-unicast-dns | TAKE | 6/9 | MISSING | Drop dns.resolve6 fallback leaking device hostname; panel only. |
|
||||
| s8-codediff-memory-ingest-default-secret-gate | TAKE | 7/8 | MISSING | Default-ON redact scan over staged ingest bytes; panel only. |
|
||||
| s8-codediff-iosqa-devicectl-typed-errors-hwudid | TAKE | 7/8 | MISSING | devicectl typed errors + hardware-UDID match; same fork commit as shortlist item 8, panel only. |
|
||||
| s8-codediff-codex-sandbox-canary | TAKE_PARTIAL | 8/7 | PARTIAL | Listed in section 3 (Contested); handle under OPEN issue #1892 on its own merits per the bin-lib refuter. |
|
||||
| gap2-skillwave-reviewer-discipline-gate-884 | TAKE_PARTIAL | 7/8 | MISSING | Requested human review as merge gate (#884); panel only. |
|
||||
| s2-runtime-workflow-hardening-tripwire-test | TAKE | 6/9 | MISSING | Free test for mutable action refs / permissions / concurrency; pairs with the sha-pins item below, panel only. |
|
||||
| s4-fleet-spec-execute-host-neutral-spawn | TAKE | 6/9 | MISSING | /spec --execute spawns `claude -p` on every host; panel only. |
|
||||
| s4-fleet-review-precision-answer-key-corpus | TAKE_PARTIAL | 7/8 | PARTIAL | Answer-key review corpus; TODOS.md already defers answer-key evals, panel only. |
|
||||
| s7-docs-method-contributing-evidence-bar-section | TAKE | 6/9 | PARTIAL | The record behind the dangling PULL_REQUEST_TEMPLATE.md:4 link (verified: grep 'evidence bar' CONTRIBUTING.md = 0). Write the section in house voice or re-point the link; do not paste fork prose. Human 2-3 hours, CC+gstack 15 min. |
|
||||
| s8-codediff-make-pdf-render-sentinel-invariant | TAKE | 6/9 | PARTIAL | Strip U+0000 at render entry, throw on surviving placeholder; panel only. |
|
||||
| s9-skills-system-functional-qa | TAKE | 8/7 | MISSING | QA for APIs/CLIs/jobs/webhooks; new skill-sized surface under the catalog ceiling, needs a product call. Panel only. |
|
||||
| s9-skills-debug-bisect-and-nonreproduction-taxonomy | TAKE_PARTIAL | 7/8 | MISSING | /investigate bounded bisect + non-reproduction classes; panel only. |
|
||||
| s9-skills-review-finding-validation-gate | TAKE_PARTIAL | 7/8 | PARTIAL | VALIDATED/REJECTED/UNCERTAIN per finding before Fix-First; panel only. |
|
||||
| gap1-s10-browsewave-chromium-path-setup-probe | TAKE | 6/9 | MISSING | setup launch probe honours GSTACK_CHROMIUM_PATH; natural rider on shortlist item 3, panel only. |
|
||||
| gap1-s10-browsewave-cookie-import-cdp-pipe | TAKE | 7/8 | MISSING | Windows cookie import over --remote-debugging-pipe; panel only. |
|
||||
| gap1-s10-browsewave-untrusted-content-hardening-prose | TAKE_PARTIAL | 7/8 | MISSING | Skill-prose hardening across investigate/browse/qa/canary/ship; overlaps the BROWSE_SETUP tie-break in section 3, panel only. |
|
||||
| gap3-codex-jsonl-parser-bin | TAKE | 7/8 | MISSING | bin/gstack-codex-jsonl-parser replacing inline python in /codex (#1329); panel only. |
|
||||
| gap2-skillwave-land-and-deploy-configurable-merge-method-726 | TAKE | 7/8 | MISSING | Configurable merge method via CLAUDE.md Deploy Configuration; fits utility.ts:65 idiom, panel only. |
|
||||
| s9-skills-ask-only-uninferable | TAKE_PARTIAL | 7/7 | MISSING | SHARED-JUDGMENT clause 10; preamble growth under the ratchet, panel only. |
|
||||
| s3-judgment-session-start-code-intelligence-offer | TAKE_PARTIAL | 6/8 | PARTIAL | Session-start indexing offer; panel only. |
|
||||
| s7-docs-method-workflow-sha-pins-and-permissions | TAKE | 5/9 | PARTIAL | SHA-pin actions + top-level permissions; CI hygiene, panel only. |
|
||||
| gap5-hygiene-chromium-path-status-correction | TAKE | 5/9 | PARTIAL | GSTACK_CHROMIUM_PATH for headless launch; panel only. |
|
||||
| s9-skills-external-effects-no-blind-retry | TAKE_PARTIAL | 7/7 | PARTIAL | EXTERNAL-EFFECTS semantics; the PR-mutations sibling was refuted (section 5), panel only. |
|
||||
| s1-prewave-uninstall-preserve-unowned-codex-dirs | TAKE_PARTIAL | 6/8 | PARTIAL | Prior refuter refuted=true: bug is real but is upstream's own TODOS.md:258-262 backlog item; do it there, not as a port. |
|
||||
| s5-beta-human-era-anchors | TAKE_PARTIAL | 7/7 | MISSING | Retire file-count/human-day proxies; touches ship/review heuristics Garry wrote, panel only. |
|
||||
| s9-skills-ship-smallest-release-convention | TAKE_PARTIAL | 7/7 | MISSING | Honour the repo's release convention at its smallest; panel only. |
|
||||
| gap3-codex-global-discover-ssh-url-form | TAKE | 5/9 | MISSING | normalizeRemoteUrl ssh:// slash form; small, panel only. |
|
||||
| s6-branches-record-tunnel-denial-and-scope | TAKE_PARTIAL | 4/9 | MISSING | Prior refuter refuted=true (already guaranteed by the closed TUNNEL_COMMANDS allowlist); folded into the record cluster in section 4. |
|
||||
| s5-beta-hung-child-stall-guards | TAKE_PARTIAL | 5/8 | PARTIAL | E2E idle-stall/wall-clock guards; overlaps v1.77 test-infra wave, panel only. |
|
||||
| gap2-skillwave-office-hours-artifact-outcome-1049 | TAKE | 6/7 | MISSING | Verify design doc exists before outcome:success (#1049); panel only. |
|
||||
|
||||
## 6. Already absorbed / superseded / not applicable
|
||||
|
||||
From records-other.json (100 ABSORBED, 25 NOT_APPLICABLE, 3 SUPERSEDED), grouped:
|
||||
|
||||
| Cluster | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Apple App Store release journey (10 items: core roll-up plus adapter before branch gate, classify errors before credentials, browser credential ban + escalation ladder, deck editor, macOS CI runner split, pricing in authorization, session-minted upload key, sticky assets, two permitted interactions) | ABSORBED | ship/sections/apple-release.md.tmpl (header credits time-attack; wave 2, then hardened with `allAppsVisible:false`) |
|
||||
| Judgment rules: design docs repo-local (#703), decision-record concision (#2000), evidence-before-claimed-limitations, third-party web actions contract, founder-resources opt-out (#538), prose fallback when no question tool | ABSORBED | office-hours/sections/design-and-handoff.md.tmpl:20-42, :343-366; scripts/resolvers/preamble/generate-evidence-directive.ts:15-17; scripts/resolvers/third-party-actions.ts:29-45; generate-ask-user-format.ts:13-37 |
|
||||
| Egress receipts, gstack-egress CLI, context-bill, discovery-surface ceiling, distill/eval model host-neutral | ABSORBED (wave 1) | bin/gstack-egress, lib/egress-receipt.ts, test/egress-receipt-wiring.test.ts; test/catalog-budget.test.ts; lib/eval-model.ts |
|
||||
| Browse daemon: bun-polyfill spawn parity, disconnect-cause null browser, numbered-profile cookies (#2139), Playwright ^1.62 pin, Windows ACL SID (#2170), closeTab race, handoff SingletonLock, stop-ack before shutdown (#2020), live-but-busy daemon preservation (#2219/#2231), lock errno honesty (#1084), extension getPort withhold (#1822), CJK IME (#1272), WS subprotocol echo, /health token removal, stealth tests, telemetry config tier | ABSORBED | browse/src/browser-manager.ts:194,986-1020,1796-1798; cli.ts:608-641,684-745; meta-commands.ts:424-436; file-permissions.ts:45-87; server.ts:1870-1915; extension/sender-auth.js; browse/src/telemetry.ts:47-91 |
|
||||
| Setup/install: --help side-effect free, gstack-paths shell quoting, settings-hook fail-closed, mirror support files (#1502), absolute bin path (#1820), investigate hook paths (#1873), freeze symlink resolution (#781), careful compound-command guard (#2039), Codex/setup wave set, codex web-search flag | ABSORBED | setup:16-49, :871, :964-967; bin/gstack-paths:16-22; bin/gstack-settings-hook:70-223; freeze/bin/check-freeze.sh:100-116; careful/bin/check-careful.sh:186-189; scripts/resolvers/constants.ts:50-65 |
|
||||
| CI / supply chain: bun-version pin, dependency-review + osv-scanner, OSV config, fork-PR secret guards, gate secret scan, workflow concurrency, basic-ftp pin, diff ^9 bump | ABSORBED | 9 workflows pin bun 1.3.13; .github/workflows/{dependency-review,osv-scanner}.yml; .osv-scanner.toml; evals.yml:78-107; .github/scripts/gate-secret-scan.mjs; test/workflow-concurrency.test.ts; package.json:59 |
|
||||
| gbrain / memory: policy tier chokepoint, ingest staging, brain-context-load cold probe, brain-sync tilde paths (#1656), decision-log --supersede, setup-gbrain provider Step 0, codex-session-import xargs -r, deprecated brain-consumer scripts deleted | ABSORBED | bin/gstack-gbrain-sync.ts:45,839-880; bin/gstack-brain-context-load.ts:72-200; bin/gstack-decision-log:57,97; setup-gbrain/SKILL.md.tmpl:107-142; bin/gstack-codex-session-import:67-71 |
|
||||
| ios-qa: boot token out of os_log, loopback bind, hashed session tokens, touch selectors out of Release | ABSORBED | ios-qa/templates/StateServer.swift.template:100-105,154-167; ios-qa/daemon/src/index.ts:365-397; DebugBridgeTouch.m.template:23 |
|
||||
| make-pdf: invisible preamble, sibling browse before PATH, absorbed fix batch (render/smartypants) | ABSORBED | make-pdf/src/render.ts:69-70,213-265,487-515; browseClient.ts:10-20 |
|
||||
| Test infra: hermetic skills config dir, no-suicide-exit guard, handoff test isolation, sharded paid tier, CI gate binaries + poppler, codex E2E MCP isolation, multi-ecosystem ship test markers, Xvfb argv0 match, design timeout/count validation, benchmark timeout validation, catalog lead periods, artifacts-init push protocol (#2225), telemetry error mapping (#769), context-recovery slug (#1851), autoplan jq bind (#2021), land-and-deploy squash readback (#2011), one-way-door reset/revoke (#2024), document-generate (#1477), Opus 4.7 migration, codex openai.yaml, Windows spawn/DACL, question-format hook outputs | ABSORBED | test/helpers/hermetic-env.ts:278; test/no-suicide-exit.test.ts; scripts/test-paid-shards.ts; free-tests.yml:92-128; codex-session-runner.ts:211-214; scripts/resolvers/testing.ts:8-50; browse/src/xvfb.ts:144-170; bin/gstack-artifacts-init:67-82,218-256; scripts/one-way-doors.ts:66-72 |
|
||||
| Nine further ABSORBED items not named in a row above: s1-prewave-sidebar-chat-era-test-deletion (#1984), gap3-test-infra-wave-fully-absorbed (14 community PRs), gap2-skillwave-env-var-resolver-paths-1704, gap1-s10-browsewave-merged-pair-blocklist-and-brain-trust-ids (#2228), gap1-s10-browsewave-update-check-hardening (#1982), gap1-s10-browsewave-spawned-session-helper-and-telemetry-state (#1188), s6-branches-investigate-brokenness-v035, gap5-hygiene-bac5d9a5-unreachable-gate-tests-closed, s5-beta-body-floor-guard | ABSORBED | One-line evidence per id in records-other.json; with these the 100 ABSORBED reconcile against the rows above |
|
||||
| GStack 2 runtime internals: execution-result envelope, release pipeline/attestation, shard heuristics, dispatcher headers and alias tables, runtime/cli main guard, deepeval/platform bakeoff, PostHog, demos fixture, Windows isolated gates harness, web-data egress rails, browser-provider eval, telemetry domain hashing, ios executor planner | NOT_APPLICABLE | No runtime/, evals/, demos/ or executor/ directories upstream; TODOS.md:460-505 and PACING_UPDATES_V0.md:97-110 record what was folded in instead |
|
||||
| Deliberate upstream decisions: generic gpt-5.6 overlay (#2245; Sol is exact-only, scripts/models.ts:26-41), Slate first-class arm (docs/designs/SLATE_HOST.md), Hermes install arm/frontmatter (hosts/hermes.ts:32-37), careful text-segment filter (#1110, a destructive-command bypass), gstack-paths --get, extension storage.session auth (POST /extension-token chosen), Apple intermediate browser-drive states (s3-judgment-apple-intermediate-browser-drive-and-asc-cli), beta-first eval recalibration, slop-diff generated exclusion, windows-setup-e2e runtime lane | NOT_APPLICABLE | As cited per row in records-other.json |
|
||||
| design daemon reset timers; Claude Desktop AUQ pre-empt (#2146); memory-ingest total-files tripwire (#2144) | SUPERSEDED | design/src/daemon.ts:206,489 (different shape); question-preference-hook.ts:21-23 never emits 'defer'; bin/gstack-memory-ingest.ts:2303-2346 reconciliation check |
|
||||
|
||||
## 7. Open time-attack PRs on upstream
|
||||
|
||||
All eight upstream PRs are OPEN and CONFLICTING as of 2026-09-02 (gh); none can merge as-is.
|
||||
|
||||
| PR | Title | Disposition | Grounding |
|
||||
|---|---|---|---|
|
||||
| #2235 | /bug-report skill | Port-the-residual, then close | Panel P2 val 7 / fit 5: a new tier-2 skill overlapping /investigate under the 1,150-token catalog cap. The independently useful piece is the redact-doc `retain` argument: scripts/resolvers/redact-doc.ts:85-88 deletes $REDACT_FILE right after the scan while the prose says "pass the SAME file downstream" (s1-prewave-bug-report-skill). Take `retain`, decline the skill. |
|
||||
| #2233 | Bound Playwright bootstrap, preserve skill registration | Close-superseded | Probe deadline absorbed (setup:417-466, #2136 CLOSED); install bound better served by PR #2359 (30 min, GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT); the continue-after-failure half lands via the gap3 re-implementation crediting #1900. Update TODOS.md:3951-3953. |
|
||||
| #2232 | Transcript trust, scope, source isolation | Port-the-residual | Take the `transcript_ingest_mode=off` slice natively now (shortlist item 9); defer scope and `--source-id` behind the never-double-store review (TODOS.md:3947) and a gbrain flag probe. Close the PR after the off fix with a pointer to the deferred design. |
|
||||
| #2231 | Preserve live daemon during busy loads | Close-superseded | browse/src/cli.ts:684-686 daemonPidAlive + probeHealthWithBackoff, :733-745 "#2219 IRON RULE"; issue #2219 CLOSED 2026-08-17 (records-other: s1-prewave-live-daemon-preservation ABSORBED). |
|
||||
| #2230 | Test fixes for 182 failures unmasked by #2172 | Close-superseded | 5 of 6 fixes absorbed or superseded plus the bunfig PATH-restore preload (s1-prewave-hermetic-test-fixes). Optional 15-minute residual: one shared `codex --version` availability helper replacing seven `which codex` gates. |
|
||||
| #2229 | Team-init cross-platform hook + Copilot lowercase skill | Close-not-wanted | Fork re-introduces the #2500 false-block (checks only ~/.claude/skills/gstack), adds a Node dependency, Copilot does not read .claude/settings.json, README hunk is a voice rewrite. Native fix instead: `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"` on Windows at bin/gstack-team-init:183-189, or route through gstack-settings-hook. |
|
||||
| #2226 | Browse local auth leak + legacy profile migration | Port-the-residual | Take only the terminal-agent WS Origin pin (shortlist item 12). auth.json deletion gated on a GBrowser source check; bin/gstack-browse-migrate, bin/gstack-browse-repair and the whole-surface Host check (#1324) stay separate decisions. Never copy the fork's extension ID constant. |
|
||||
| #2225 | artifacts-init HTTPS push remotes | Close-superseded | bin/gstack-artifacts-init:11, :67-82, :218-256 `--push-protocol` incl. `preserve` and `gh config get git_protocol`; test/gstack-artifacts-init.test.ts:277-380 carries the same test titles. |
|
||||
|
||||
Fork's own open PRs:
|
||||
|
||||
| PR | Title | Disposition |
|
||||
|---|---|---|
|
||||
| time-attack #54 | $B record (CDP screencast) + iOS session poller | Do not merge as-is. Treat as the design alternative when triaging #2497 (TODOS.md:43): fix SCOPE_READ to a stricter scope, no spawnSync ffmpeg in the daemon, wall-clock cap, frame cleanup, .webm for VP9, tunnel-denial pins (s6-branches-record-tunnel-denial-and-scope), then land one implementation with @Topherhindman and @itstimwhite credit. The iOS poller (s6-branches-ios-record-session-poller) follows the same decision. |
|
||||
| time-attack #40 | Web-data provider contract (Firecrawl/Exa/Context.dev/Aside) | Not wanted now (CONFLICTING on the fork too). Revisit only at the Aside Phase 2 product call (TODOS.md:462-486); upstream deliberately ships no off-machine scraping providers (s6-branches-web-data-provider-cli, s6-branches-web-data-task-taxonomy-and-rankings). |
|
||||
| time-attack #55 | AGENTS.md Cursor Cloud notes | Do not put in AGENTS.md (upstream's is the generated catalog). The useful residual is routing raw `git commit` calls in test/diff-scope.test.ts:21-31, gstack-version-bump, team-mode, worktree and gstack-repo-mode tests through test/helpers/scratch-repo.ts (gpgsign/fsmonitor neutralised) plus one CONTRIBUTING.md:175-177 sandbox sentence (s7-docs-method-agents-md-cursor-cloud-gotcha). |
|
||||
|
||||
## 8. Suggested port order
|
||||
|
||||
1. **Wave A: setup and hook correctness (three bisected commits, one PR).** Playwright best-effort block (credit #1900), check-freeze STATE_DIR line plus test pins (credit #1509), gstack-relink ownership gate plus tests (credit #2119). Effort: human ~1.5 days, CC+gstack ~1 hour plus one free-suite run.
|
||||
2. **Wave B: stale community PR merges and contributor-doc fixes.** #1777 (taste confidence), #1096 (Render key), optionally #2141 if the checklist growth is accepted, the #1772-equivalent Codex/Factory qa-only pathRewrite with golden regen, and the CONTRIBUTING "The evidence bar" section or link re-point (s7-docs-method-contributing-evidence-bar-section). Effort: human ~half a day, CC+gstack ~45 min.
|
||||
3. **Wave C: browse, make-pdf, ios small fixes.** make-pdf preview offline gate, click no-second-wait, ios proxy timeout (adapted), terminal WS Origin pin, welcome.html system fonts, dangling-symlink validateOutputPath, Ubuntu userns relaunch-once (closes #2157). One commit each. Effort: human ~2.5 days incl. an Ubuntu 24.04 check, CC+gstack ~2 hours.
|
||||
4. **Wave D: /ship platform-agnostic purge.** tests.md.tmpl Steps 5/6 rewrite, ship/SKILL.md.tmpl:381 evidence pairs, readiness-gate default, `gh pr checks` field fix, goldens regen, marker test extension (credit #1102, close #1069). Effort: human ~2.5 days, CC+gstack ~2 hours.
|
||||
5. **Wave E: consent and state hygiene.** `transcript_ingest_mode=off` honoured plus transcript-gate prose; GSTACK_HOME in update-check/telemetry-log/detach; brain-cache config path; gstack-config inline comments; detached-HEAD slug; v1.27 migration owner qualification; gbrain-exec DATABASE_URL strip (adapted); `gstack-egress grants` under-enumeration (update_check, codex_reviews, pair_agent). Effort: human ~2.5 days, CC+gstack ~2 hours.
|
||||
6. **Wave F: contested items, only after Garry's calls in section 3.** Office-hours #879 one-sentence fix, Access Control bullet, opt-in reply_language, UNTRUSTED_CONTENT_WARNING into BROWSE_SETUP, egress receipt at codex dispatch, Codex sandbox canary under #1892, literal-`main` fixes at utility.ts:111-112 and design.ts:83, codex stdin dispatch after the gstack-codex-probe:194 wrapper fix. Effort: human ~3 days, CC+gstack ~2.5 hours, plus one paid AUQ/ship eval run for anything touching the preamble. The 39 unrefuted P1 items in section 5a are not scheduled; each needs the same grep-verify-or-refute pass the 48 received before it earns a wave.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Fork-port residual evaluation: provenance
|
||||
|
||||
Evaluated 2026-09-02. Read-only; see REPORT.md for the maintainer-facing result.
|
||||
|
||||
| Ref | SHA |
|
||||
|---|---|
|
||||
| time-attack/gstack main (fork tip) | `0aca1f77dec3766c1627e219653ab181d380b542` |
|
||||
| garrytan/gstack main (upstream HEAD at evaluation) | `0d1bd5616c0ef096bb7ccee336f63c60ee408618` (v1.79.0.0) |
|
||||
| merge-base | `7c9df1c568a9ea745508f679a329332b2c338063` |
|
||||
|
||||
## Files
|
||||
|
||||
| File | sha256 | What |
|
||||
|---|---|---|
|
||||
| REPORT.md | `087a8476c3bb8d081626ee55eff00845a18ef2e8cbec049ce1c63ec860a33bb4` | eight-section report (shortlist, contested, defer, skip, absorbed, PR dispositions, port order) |
|
||||
| residual-index-lite.json | `a342e6cd7b57e08b05ad2355e04765e995496dbf7bbb65399ea38c8a4b344644` | 287 residual items: cross-reference status, panel verdict, refuter pointer |
|
||||
| records-other.json | `b13e64596bee93ceffb4161d30dc30f9cc6f5fd2db3dd0184bc31187b328a19f` | 128 items already absorbed / superseded / not applicable, one-line evidence each |
|
||||
| refuters.json | `4c7740a2f5bd0978f564468cab18d9a5945cb2a38108759a23d74900f2af2b0f` | 48 adversarial refuter verdicts on the top-ranked candidates |
|
||||
| summary.json | `53b3c9c45f62d49166c5cd36a1ef383da60cd899b7e2110851b13f94731db1dc` | counts |
|
||||
|
||||
Method: two research workflows (sweep + cross-reference + panel; refute + synthesize + fact-check), then a CEO review (HOLD SCOPE), an eng review, and two Codex outside-voice passes. The full-text residual index (2.9MB) was not committed; the lite index carries every id, status, verdict, and truncated residual text.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
[
|
||||
{
|
||||
"id": "s3-judgment-self-contained-questions",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The premise is real but narrow, and the fork's cure is over-broad. The only reported failure is one flow: office-hours Phase 5 writes the design doc to disk (office-hours/sections/design-and-handoff.md.tmpl:18-19) and then asks Approve/Revise/Start over (:168-172) with no instruction to show the body \u2014 issue #879 (1 comment, 0 reactions, Apr 2026) describes exactly that. The premises case is already handled: office-hours/SKILL.md.tmpl:214-220 is a fenced OUTPUT template ('Output premises as clear statements... PREMISES: 1. ...') that the model prints before :222's confirm \u2014 the fork's 'never ask about premises that exist only in reasoning' is already the instruction. The fork's generalization (a6bc2de3:scripts/gstack2/bug-fix-overlays.ts:367-383, targets '*', appended verbatim to all 44 modules) says 'render that content in FULL as direct assistant text' before ANY confirm/approve/rank/choose question about findings, plans, scores or summaries \u2014 applied to /review's Fix-First batches, plan-review scorecards and /ship, it re-emits content the user just watched being produced and roughly doubles output tokens per gate, while upstream's always-loaded AUQ Format (scripts/resolvers/preamble/generate-ask-user-format.ts, 116 lines: D<N>, ELI10, Stakes, per-option pros/cons, Net) already forces the decision-relevant restatement into every brief; the Conductor prose path is assistant text by construction. The rule also hardcodes a host UI behaviour ('collapsed tool output (Bash cat, Read) does not count') into host-agnostic preamble prose \u2014 Codex/Cursor/Factory renders differ and Claude Code's collapsing changes across versions. The fork's 'regression fixture' (evals/parity/regressions/pr-879.json) is a JSON of expected booleans with no behavioural test; nothing shows the paragraph changes model behaviour. Upstream's #703 dual-write (design-and-handoff.md.tmpl:20-36) already puts the doc at docs/designs/ where the user can open it. Verified absence upstream: grep 'collapsed|cannot see|as direct assistant|render.*before.*ask|self-contained' over generate-ask-user-format.ts, preamble.ts, question-tuning.ts, office-hours/*.tmpl = 0 hits.",
|
||||
"conditions_to_take": "Take only the office-hours-specific fix (the #1116 idea, credit @RyanAlberts and close #879/#1116): one sentence at design-and-handoff.md.tmpl:168 \u2014 print the saved path plus the doc body as assistant text (not via Bash/Read) immediately before the Approve/Revise/Start-over AskUserQuestion, skipped when SESSION_KIND is spawned. At most add one self-check bullet to generate-ask-user-format.ts ('the artifact this question approves is visible in the conversation above'). Do NOT add the 'render in full' universal rule or the 44-copy overlay; write in upstream voice.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-review-small-diff-sweep-hole",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The fork's justification \u2014 'Nothing else picks these up \u2014 there is no specialist pass' (0aca1f77:skills/review/references/legacy/review.md:442) \u2014 is true of the fork's 2.0 tree and false upstream. Upstream dispatches Testing + Maintainability on every diff \u226550 lines, Security on SCOPE_AUTH or backend>100, Performance by scope (scripts/resolvers/review-army.ts:59-70), AND runs a fresh-context Claude adversarial subagent on EVERY diff regardless of size ('### Claude adversarial subagent (always runs)', scripts/resolvers/review.ts:509; ship/sections/adversarial.md:71; ship/SKILL.md:602 'Every diff gets both Claude adversarial subagent and Codex adversarial challenge') whose prompt explicitly hunts 'security holes... trust boundary violations'. So the fork's field case (an unauthenticated file-read endpoint in a small diff 'passed the Normal rubric clean') faced no adversarial pass in the fork; in upstream it meets one on every review. What actually exists upstream is one inaccurate sentence \u2014 review/checklist.md:10 'handled by parallel subagents, NOT this checklist' with no 'when specialists are skipped' clause \u2014 plus no Access Control bullet in the CRITICAL pass. Porting the fork's always-on Pass 3 (six categories run by the main agent on every diff: 0aca1f77:...checklist.md:127-160) double-covers \u226550-line diffs against the Maintainability and Security specialists (near-duplicate findings escape the exact-fingerprint dedup at review-army.ts:145-148), adds ~1.5KB to a forced-read file on every /review and /ship (context-budget ratchet), and the fork's test pins a category list across three files \u2014 coupling upstream does not need. Dead Code / Magic Numbers / Test Gaps on a <50-line diff are the lowest-yield categories in the set.",
|
||||
"conditions_to_take": "Minimal wording fix, not a port: rewrite review/checklist.md:10 to say specialists cover these on \u226550-line diffs and that on small diffs / codex host the reviewing agent covers them itself plus the always-on adversarial subagent; add an 'Access Control' bullet (missing auth middleware, IDOR, path traversal / unauthenticated file reads) to Pass 1 CRITICAL since Security is the hardest-gated specialist (review-army.ts:66) and mirror it in review/SKILL.md.tmpl:129-131 and ship/sections/review-army.md.tmpl:9-11; refresh test/fixtures/context-budget.json. Skip the Pass 3 section, the three-file pin test, and any quality_score schema change.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-setup-install-deadline-and-continue",
|
||||
"refuted": true,
|
||||
"strongest_argument": "This is a duplicate of gap3-setup-playwright-best-effort-not-fatal wearing a deadline. Its only user-valuable residual \u2014 keep installing skills when Chromium fails \u2014 is exactly gap3's item (same setup:842-851 `exit 1`, same setup:1657 ordering). The extra half, a wall-clock deadline on `bunx playwright install chromium` (setup:802-808), is unnecessary and mildly harmful: Playwright's own installer already retries 5 times with a per-socket timeout (microsoft/playwright packages/playwright-core/src/server/registry/browserFetcher.ts:50-66 `retryCount = 5`, `downloadSocketTimeout`; env PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT), so a stalled download already fails on its own \u2014 the #2136 hang was in the launch PROBE, which upstream already bounds (setup:428-466 `_wait_with_deadline $! 90`). A fixed 300s default (fork 5f9d4d87) kills a legitimate ~170MB download on the slow corporate links the change claims to help and leaves a partial cache. The fork commit itself does not compose with upstream: it deletes `trap cleanup_copied_bun EXIT`, adds a whole-setup mkdir lock under GSTACK_HOME (upstream chose the narrower `_PW_LOCK`, setup:785-819), uses TERM\u21920.1s\u2192KILL plus a marker-file watcher instead of upstream's `_kill_tree`/`_wait_with_deadline`, and its Windows `taskkill //T //F` branch has no test anywhere. TODOS.md:3951 already tracks #2233 as 'verify and close or extract the remainder' \u2014 the remainder is gap3.",
|
||||
"conditions_to_take": "Do not take as a separate item; fold the 'continue to skill registration' half into gap3. If a download bound is ever wanted, pass PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT through (Playwright's own knob) rather than a wall-clock kill. Close upstream PR #2233 with that receipt once gap3 lands.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "gap2-skillwave-hooks-envelope-and-state-dir-chain-1509",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Cannot refute: this is a verified fail-open in a deny-tier hook. freeze/bin/check-freeze.sh:35 reads `STATE_DIR=\"${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}\"` (grep GSTACK_HOME|gstack-paths|GSTACK_STATE_ROOT in the file = 0), while every writer resolves through gstack-paths GSTACK_HOME-first (freeze/SKILL.md.tmpl:61-64, guard/SKILL.md.tmpl:70-73, unfreeze/SKILL.md.tmpl:31-35, investigate/SKILL.md.tmpl:131-134; bin/gstack-paths:28-29). GSTACK_HOME is a documented production knob, not a test-only one (bin/gstack-config:16 'override ~/.gstack state directory'; bin/gstack-skill-start:54; setup:2135,2558), and the same hook's own analytics helper already honours it (careful/bin/hook-extract.sh:67 `${GSTACK_HOME:-$HOME/.gstack}`). With GSTACK_HOME set, /freeze prints 'boundary set' and the hook hits the no-file \u2192 `{}` allow branch (:39-42), contradicting its header 'a boundary that fails open is not a boundary' (:8-12). Issue #1459 and PR #1509 remain OPEN; no upstream commit ever touched the STATE_DIR line (git log -S CLAUDE_PLUGIN_DATA \u2192 only c4f679d8, 2026-03-18). The only thing to refute is the fork's SHAPE: bbad1bab's 12-line probe-and-eval of gstack-paths inside a PreToolUse hook that fires on every Edit adds a subprocess and a new failure mode for no gain.",
|
||||
"conditions_to_take": "Port the idea, not the hunk: one line, `STATE_DIR=\"${GSTACK_HOME:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}\"`, mirroring hook-extract.sh:67 (no gstack-paths eval in the hook). Because test/hook-scripts.test.ts runHook inherits process.env (:16), update every freeze test (:690-893) to pass `{ GSTACK_HOME: stateDir, CLAUDE_PLUGIN_DATA: stateDir }` as fork c896016b does, and add the missing regression: GSTACK_HOME pointing at a dir WITH freeze-dir.txt while HOME/CLAUDE_PLUGIN_DATA point elsewhere must deny. Credit @NikhileshNanduri (#1509), close #1459.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-ship-hardcoded-test-lanes",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Best refutation available is 'already mostly mitigated': TEST_BOOTSTRAP (scripts/resolvers/testing.ts:6, landed v1.65 2be6c06b) now says 'If [CLAUDE.md] documents a test command... use that command in Step 5', so the #1069 Django failure (0 reactions, 1 comment) is narrowed to projects with no documented command, where Step 4 asks and persists. That does not hold up: ship/sections/tests.md.tmpl:9-22 is still a literal bash block that runs `bin/test-lane 2>&1` and `npm run test 2>&1` and warns about `RAILS_ENV=test bin/rails db:migrate`; Step 6 (:44-113) matches `app/services/*_prompt_builder.rb` globs 'from CLAUDE.md', maps `*_eval_runner.rb`, runs `EVAL_JUDGE_TIER=full bin/test-lane --eval`, and ships a Haiku/Sonnet/Opus price table; ship/SKILL.md:674 forces every /ship to read this file in full, and Step 16 hardcodes the `tests`/`vitest` label pair (ship/SKILL.md.tmpl:381). The v1.66.1 evidence-wrapper commit (1cab5e11) wrapped the hardcode rather than removing it, so this survived both port waves. It is a direct violation of upstream's own CLAUDE.md \u00a7Platform-agnostic design. Nothing is portable from the fork (its version is a 2.0 legacy/ship.md overlay) and PR #1102 targets the pre-carve ship/SKILL.md.tmpl \u2014 this is upstream's own cleanup, mostly deletion.",
|
||||
"conditions_to_take": "Native rewrite, own PR: Step 5 consumes the command Step 4 resolved (CLAUDE.md \u2192 detected+asked+persisted; one-line skip if none; 'never fall back to a baked-in Rails or Node command'), one gstack-evidence label per resolved command; Step 6 conditional on a CLAUDE.md-declared eval command, delete the Rails glob list, runner mapping and price table; Step 16 evidence check becomes one --label/--expect-cmd pair per Step 5 command (bin/gstack-evidence check already parses repeated pairs, :417-418); same fix at land-and-deploy/sections/readiness-gate.md.tmpl:111-116 (`bun test` default). Regenerate the three goldens that pin `test-lane` (test/fixtures/golden/{claude,codex,factory}-ship-SKILL.md), extend test/ship-test-detection-markers.test.ts to reject bin/test-lane|RAILS_ENV|_prompt_builder|EVAL_JUDGE_TIER in rendered output. Credit @JiayuuWang (#1102), close #1069.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "gap3-setup-playwright-best-effort-not-fatal",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Cannot refute on substance. setup:3 `set -e` plus the bare `bunx playwright install chromium` at setup:806 means any download failure aborts the script on the spot (before even reaching the `exit 1` at :850), and both sit under '# 2.' (setup:764) ahead of '# 4. Install for Claude' (:1657, link_claude_skill_dirs :1676), '# 8. migrations' (:1943) and '# 10. hooks' (:1990): an offline/proxy/AppArmor/Bun-on-Windows box ends with zero skills and a re-run that dies at the same line. Playwright already retries the download 5\u00d7 internally (browserFetcher.ts:50-66), so what reaches this path is genuine unavailability \u2014 precisely when the non-browser 35+ skills matter most. Everything after step 2 is already best-effort (emoji font :853-864 warn-only, telemetry, hooks), so the abort is an inconsistency, not a policy. Demand is real if quiet: #1900, #1901, #1902, #2233, #913 all OPEN on this one behaviour. The refutations that do land are about the fork artifact, not the idea: ce9f0f9a lives only on a fork backup branch (not in fork main or bb57306d), its message 'Skills, hooks, and migrations are already installed' is false in upstream ordering, and its block-wide 'no exit 1' test regex would flag upstream's `_PW_LOCK` contention exit (:815-819) which should stay fatal.",
|
||||
"conditions_to_take": "Re-implement on upstream's block (not a cherry-pick): wrap the install in `if ! ( ... )` so set -e cannot abort; `_PW_FAIL_REASON` accumulator (chromium-install | windows-no-node | windows-node-modules | post-install-launch); keep lock contention fatal; keep _clear_playwright_quarantine, _PLAYWRIGHT_PLATFORM_OVERRIDE and the EXIT-trap chaining intact; loud stderr block naming the unavailable skills (/qa, /design-review, /browse, make-pdf, /pair-agent) and the retry command, repeated in the final setup summary; optionally honour GSTACK_SKIP_PLAYWRIGHT=1 (#913) in the same block and have `browse` print a directed 'Chromium not installed at setup \u2014 run ./setup' instead of a Playwright stack trace. Static test anchored to the three Chromium-failure sites (fills TODOS.md:199-202). Credit @DavidMiserak (#1900); close #1900/#1901/#1902/#2233.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s4-fleet-codex-consent-and-redact-at-sink",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The scan is security theatre at these sinks and the consent duplicates an existing switch. Every outside-voice dispatch runs Codex as a LOCAL AGENT with a read-only filesystem sandbox rooted in the repo: scripts/resolvers/review.ts:537 tells it 'Run DIFF_BASE=$(git merge-base ...) && git diff' itself (`-C \"$_REPO_ROOT\" -s read-only`), :570 `codex review --base` has the CLI compute the diff itself, :373/:671/:811 pass a prompt but still `-C \"$_REPO_ROOT\" -s read-only`; codex/sections/consult-mode.md.tmpl:79 and challenge-mode.md.tmpl:36 likewise. Codex can and does read any file in the tree (.env, fixtures, configs) regardless of what gstack scans, so scanning a separately materialized `git diff` neither gates what leaves the machine nor satisfies CLAUDE.md's rule \u2014 it is literally 'scan a string then re-render', the pattern CLAUDE.md \u00a7Redaction forbids. /spec's pre-codex scan (spec/sections/gate-and-file.md.tmpl:47) is different in kind: the spec body IS the entire payload. The only exact-bytes site, review-mode.md.tmpl:71-87's prompt file, still runs `codex exec -s read-only` in the repo, so the scan catches only secrets that are in the diff \u2014 which the pre-commit/prepush redaction and PR-body scan already cover at their own sinks. Costs are concrete: gstack's own diffs carry the redaction regression corpus (review.ts:514 already has to tell Codex so), so HIGH hits would block Codex review of gstack itself; MEDIUM\u2192AskUserQuestion per finding collides with autoplan's no-questions contract and the v1.78 AUQ-collapse work; five skills' templates grow against the context-budget ratchet. Consent: the user installed `@openai/codex`, ran `codex login`, and upstream ships a documented global master switch (`codex_reviews`, bin/gstack-config:115-124) that autoplan honours first (autoplan/SKILL.md.tmpl:260-266); /codex's own description says 'OpenAI Codex CLI wrapper'. A per-repo consent file is a new question in an automated pipeline for a tool the user chose, framed by the fork's PRIVACY.md posture ('auth presence is not consent'), which is not upstream policy.",
|
||||
"conditions_to_take": "Not this item. If the maintainer wants the receipts rule honoured, the cheap consistent change is an egress receipt (fail-open, user-facing class, via bin/gstack-egress-lib.sh) at each codex dispatch \u2014 test/egress-receipt-wiring.test.ts:138-143 currently exempts skill prose, which is the actual gap. Optionally one disclosure line in the codex preflight output ('sends repo content to OpenAI via your Codex login'). No per-repo consent store, no diff materialize-and-scan.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-review-loosened-inputs-stale-strings",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Nothing here comes from the fork: 2e369533 is a byte-for-byte reconstruction of @aversini's upstream PR #2141 (gh pr diff 2141 matches the fork hunks; the fork only skipped the heading rename), so 'take from time-attack' is the wrong frame \u2014 this is a stale community PR (OPEN since 2026-06-30, 2 comments, 0 reactions) that Garry has not merged. On merits it is marginal: review/checklist.md is a forced read on every /review AND /ship, so the ~250 tokens of two app-specific anecdotes (isPdfFile/updateFileMimeType, 'Total file size exceeds\u2026') are paid on every review forever and count against the context-budget ratchet; the 'Loosened acceptance' case is a modest extension of an existing CRITICAL rule (checklist.md:63-68 already says 'Trace it through every consumer... requires reading code OUTSIDE the diff'), and 'Stale User-Facing Strings' overlaps the Maintainability specialist's 'Stale Comments & Docstrings' lens (review/specialists/maintainability.md:25-29). There is no eval or E2E evidence the model misses these classes today. PR #2141's heading rename would break test/skill-validation.test.ts:893-897 ('Enum & Value Completeness' pinned) and review/SKILL.md.tmpl:130,134 cite the heading verbatim. Upstream's checklist is Garry's curated corpus of his own misses; absorbing one contributor's two anecdotes is how a checklist becomes a wiki.",
|
||||
"conditions_to_take": "If wanted, merge PR #2141 directly (credit @aversini, no fork involvement): keep the 'Enum & Value Completeness' heading (test pin), compress each anecdote to a single clause, add 'Stale User-Facing Strings' to review/SKILL.md.tmpl:132 and the diagram at checklist.md:125-135, regen, re-run bun test/helpers/capture-context-budget.ts if the ratchet trips. Skip the fork's SHARED-JUDGMENT one-liner.",
|
||||
"verdict_confidence": 5
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-match-user-language",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified: scripts/resolvers/preamble/generate-voice-directive.ts (both tiers) has no language line, and grep for user's language / reply in / non-English across scripts/resolvers, hosts/, and every *.tmpl finds only the CJK \\u-escape encoding rule (generate-ask-user-format.ts:92-97). But the port does not deliver the value the panel sells. The fork's shipped wording (466de5fe: 'This is chat prose only; files, code, commits, and PR bodies follow the repo's conventions') explicitly EXCLUDES files, and the artifacts the panel names as the payoff (ELI10 briefs in AskUserQuestion payloads, design docs under ~/.gstack/projects/<slug>/*-design-*.md, /qa-only and /retro reports, plan-review write-ups) are either files or tool payloads, so under the fork's own rule they stay English; the 'report IS the work' skills are exactly where the scoping clause is ambiguous. The fork wrote and re-scoped the line within three hours on one day (ce7cddce 14:05 -> 466de5fe 17:18) with no non-English tester, and time-attack's own 2026-07-15 comment on PR #679 lists the untested cases (mixed-language prompts, explicit overrides, technical-text preservation) that the fork also never tested. The line is always-on in every skill on every host (context-budget ratchet + golden regen), and the fork inserted it as the FIRST line of Garry's Voice section with the phrasing 'Chinese in, Chinese out', which is a voice-section rewrite under the community-PR guardrail. Base models already mirror the user's language for chat prose by default; the residual problem (templated report boilerplate) is not solved by a chat-only directive. No upstream issue or TODOS entry evidences the failure.",
|
||||
"conditions_to_take": "Take only as an opt-in: a gstack-config key (e.g. reply_language, mirroring explain_level) rendered into the Writing Style section (tier>=2, scripts/resolvers/preamble/generate-writing-style.ts), not into Garry's Voice block, with an explicit statement of which artifacts it covers (chat prose AND AskUserQuestion briefs AND report-shaped skill output; never code, commits, PR bodies) plus the accuracy carve-out for identifiers/commands/quotations. Requires the mixed-language / override / technical-text tests time-attack asked for on #679, the context-budget fixture refresh, and an AskUserQuestion to Garry before touching the Voice section. Credit @shrektan and close #679.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s4-fleet-hardcoded-origin-main-residue",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The three sites are real (review/checklist.md:5, review/greptile-triage.md:86, office-hours/SKILL.md.tmpl:87 all say `git diff origin/main`), but the residual is mis-scoped as a fork port and mis-rated as P1. (1) Impact is near zero: checklist.md:5 is a descriptive label read AFTER the calling skill has already computed the diff against the detected base (review/SKILL.md.tmpl:44 `DIFF_BASE=$(git merge-base origin/<base> HEAD)`; ship/sections/review-army.md.tmpl:6 already says `git diff origin/<base>`; land-and-deploy readiness-gate.md.tmpl:74 reads the checklist after its own {{BASE_BRANCH_DETECT}} at land-and-deploy/SKILL.md.tmpl:29). Same for greptile-triage.md:86 inside /ship. office-hours:87 is a `--stat 2>/dev/null` context nicety on a planning skill; worst case it prints nothing. Nothing here 'tells the agent to diff against a ref that may not exist' as the primary diff command. (2) Nothing is portable from the fork: 8bc0a04f edits skills/*/references/artifacts/* in the GStack-2 tree across 16 unrelated files, and the fork's test/skill-base-detection.test.ts BANNED regexes would immediately red the upstream suite on the eight `|| echo main` gh-first probes (review/SKILL.md.tmpl:94, land-and-deploy:192, readiness-gate:157,173,178, merge-and-deploy:169, landing-report:46, benchmark:63) that upstream's tripwire (test/skill-validation.test.ts:600-604) deliberately allowlists. Those are verified probes with a last-resort fallback, not hardcodes. (3) The fuller BASE-DETECTION contract rewrites {{BASE_BRANCH_DETECT}} (scripts/resolvers/utility.ts:21-51) in every PR-targeting skill and every rendered SKILL.md; the panel itself splits it off. What remains is a three-sentence upstream lint fix with no fork lineage.",
|
||||
"conditions_to_take": "Do the three one-line prose edits (say 'the diff against the base branch' / `<base>`) as an upstream housekeeping commit, and widen the tripwire's FILE list to all *.tmpl plus review/checklist.md and review/greptile-triage.md with gstack-upgrade/SKILL.md.tmpl:148,162 allowlisted. Do NOT adopt the fork's `|| echo main` / `rev-parse --verify origin/main` banned shapes (they contradict utility.ts:47-51's own fallback chain) and do NOT bundle the BASE-DETECTION resolver rewrite; if wanted, file that as a separate P2 TODO. Rate P3, not P1.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s6-branches-web-data-official-api-first-rule",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified (no 'official API' / 'MediaWiki' guidance in scrape/SKILL.md.tmpl, plan-*-review sections, or resolvers), but the sentence is being lifted out of the context that gave it meaning. In the fork it is one clause of WEB-DATA.md (857e6d62), a Firecrawl/Exa/Context.dev/Aside provider-selection contract: 'first check whether the data source has an official API ... needs no consent question' is a short-circuit on the provider CONSENT flow, which upstream does not have. Stripped of that, it is the generic truism 'check for an API before scraping', which upstream already expresses at the right altitude: the tier-3 Search Before Building reuse ladder (scripts/resolvers/preamble/generate-search-before-building.ts:9-14, rendered into plan-ceo-review and plan-eng-review, both preamble-tier 3), plan-ceo-review/SKILL.md.tmpl:235-236 'What existing code already partially or fully solves each sub-problem? Is this plan rebuilding anything that already exists?', and plan-eng-review/sections/review-sections.md:527-528. The motivating incident (a /plan session hand-rolling ~1000 Wikipedia scrapes, fork CHANGELOG d3987bb3) is a planning failure, yet the panel's primary target is /scrape, whose contract is explicitly one page, one shot, read-only via $B (scrape/SKILL.md.tmpl:26-35, :143 'Multi-page crawls (this is one-shot per call)'); a bulk-acquisition lens does not belong in a tier-1 200ms skill. Adding domain-specific bullets ('Wikipedia has MediaWiki') to generic lenses is exactly the per-anecdote list growth the context-budget ratchet exists to resist, and 'an official API beats every scraping provider' is stated as an absolute in a judgment skill where it is often false (auth, rate limits, ToS, coverage). Single anecdote, no upstream issue, no test pins to protect because there is no behavior, only a sentence.",
|
||||
"conditions_to_take": "At most one clause, in Garry's words, appended to the existing reuse ladder or the plan-review 'What already exists' lens (e.g. 'for any data the plan pulls from the web, the source's own API is rung one'), no example vendor names, no pin test, no /scrape change, no context-budget growth beyond the fixture refresh. Only worth doing if a second real plan-review miss of this shape is observed upstream.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-pre-existing-requires-base-run",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified: scripts/resolvers/preamble/generate-test-failure-triage.ts:17-22 classifies from `git diff origin/<base>...HEAD --name-only` and never asks for a base-branch run. But the fork contributed a sentence, not a mechanism (7da6b8f6 adds one line to SHARED-JUDGMENT.md:15 tail and VERIFICATION-CONTRACT.md rule 4 plus a test that pins the sentence), and everything that would make it work upstream (worktree recipe, failing-files-only scope, restore, evidence label) is new design the panel invented. That design has real costs the fork never faced: running a user's test suite at a different commit needs the base's dependencies (lockfile drift means a fresh worktree has no node_modules / vendor), env, DB, and fixtures; on many repos tests reset databases or seed data, so silently running them in a second checkout during /ship is a side effect the user did not consent to; and the run adds minutes to the ship gate for the minority case. Upstream already fails safe in the direction that matters: 'When ambiguous, default to in-branch. Only classify as pre-existing when you are confident' (:20), and every pre-existing label still routes through an AskUserQuestion where the recommended option is fix-now or blame (:42, :57), so a red test only ships if the USER picks the 3/10 'Skip' option, which is user sovereignty, not the skill waving it through. Upstream's CLAUDE.md:597-609 protocol governs gstack's own E2E evals where the maintainers know the cost; generalizing 'run it on base' to every user repo is not a port of that. The fork rule also ignores the cheap probe upstream's own evidence directive prizes: base-branch CI status (`gh run list --branch <base>` / `glab ci status`) answers 'does it fail on base' in one second with zero side effects.",
|
||||
"conditions_to_take": "Rewrite T1 vocabulary only: replace 'confident' with 'verified / unverified', tell the agent to check the base branch's latest CI status via gh/glab as the default evidence, offer a throwaway-worktree run of ONLY the failing test files (test command from CLAUDE.md, never hardcoded) as an opt-in AskUserQuestion option rather than an automatic step, and route 'unverified' to STOP rather than to the Skip option. Regenerate ship goldens; keep test/skill-validation.test.ts:1987 passing. No gstack-evidence ledger label needed.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-review-claudemd-review-section",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified (no '## Review' reader in review/SKILL.md.tmpl or scripts/resolvers/review.ts:431-478; PR #452 OPEN, CONFLICTING, no maintainer response since 2026-04-07) and the fork adds nothing beyond #452 (ec0b8515 is a one-paragraph compression in a legacy overlay). The underlying user problem is already solved by the host: Claude Code loads the project CLAUDE.md into every session's context automatically, and Codex does the same with AGENTS.md (hosts/codex.ts:36 pathRewrites confirm the equivalence). A team that writes '## Review: never AUTO-FIX under app/services/billing/**; treat db/migrate/** as high-risk' in CLAUDE.md today is already read by the agent running /review; upstream's own CLAUDE.md steers review behavior this way (Community PR guardrails, E2E blame protocol). Upstream's other CLAUDE.md-section readers are grep-parsed CONFIG (utility.ts:65 '## Deploy Configuration' key: value pairs; testing.ts '## Testing' run command), not free prose, so the 'consistent with upstream idiom' argument does not hold. The panel's own security refinement (read from origin/<base>, tighten-only) shows the hazard and is unenforceable in prose: #452's example section literally includes suppression rules ('Known hotspot ... do not flag it as dead code'), and an agent cannot mechanically tell tightening from loosening in free-form bullets, so a PR can pre-suppress its own findings through the same diff. Net: a template paragraph, three golden regens, and a context-budget bump to instruct the agent to do what the harness already does.",
|
||||
"conditions_to_take": "Document it instead of coding it: a short docs/skills.md paragraph under /review saying CLAUDE.md prose (a '## Review' section is a fine convention) already calibrates /review and /ship, with the example list from #452, credited to @evb87-tech; then close #452. Only add a template step if a reproducible case shows the agent ignoring CLAUDE.md review guidance that is present in context.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "gap3-relink-ownership-guard-on-prefix-flip",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute. Re-verified in bin/gstack-relink:46-53: `_cleanup_skill_entry` does `rm -f` on any symlink and `rm -rf` on any dir with a symlinked SKILL.md with no readlink check, is called on every flat name in prefix mode (:93) and every gstack-* name in flat mode (:99), and setup runs relink as a self-healing step on every install (setup:1679-1685). Upstream has already adopted the opposite policy at every sibling site (setup:1040-1057 cleanup_old_claude_symlinks readlink gate, bin/gstack-uninstall:204-230 readlink + inventory + banner gates), so this is the one remaining unguarded deleter of a class upstream has fixed three times. Best counter-arguments, none decisive: the blast radius is narrower than the panel implies (only prefix-mode users, only foreign skills that are symlinks or symlinked-SKILL.md dirs sharing a gstack name; a real dir with a real SKILL.md is untouched), and what is deleted is the registration link, not the user's skill content, so 'silent data loss' overstates it; and the fork patch (5ed18036, PR #2119) is not takeable verbatim: its `\"$INSTALL_DIR\"/*|gstack/*|*/gstack/*` case misses RENDER_DIR (`~/.gstack/render/claude`, relink:43), which would stop cleaning stale brain-aware entries and recreate the duplicate-name #2511 hazard, and time-attack's own #2119 review flags the lexical `*/gstack/*` match as able to delete non-owned paths. Also the flat-mode writer (relink:104) still clobbers a foreign symlink unconditionally, so the fork does not fully achieve 'foreign skills survive'. These are port corrections, not reasons to leave the bug open.",
|
||||
"conditions_to_take": "Re-implement rather than cherry-pick: gate `_cleanup_skill_entry` on the resolved (realpath) target being contained in `$INSTALL_DIR` or `$RENDER_DIR` (plus upstream's existing `*/.gstack/render/claude/*` for parity with setup:1051), never a bare `*/gstack/*`. Port smblight's two regression tests (helpers run()/setupMockInstall() exist at test/relink.test.ts:24,48) and add a RENDER_DIR case. Decide the flat-mode collision policy explicitly (loud 'name collision, skipping' rather than silent overwrite at relink:104 and the alias writers relink:55-68 / setup:991-1003). Skip the `<!-- gstack-generated-alias -->` marker and hermes pieces. Credit @smblight (#2119). P2, not P1.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "gap2-skillwave-review-defensive-framing-shared-resolver-1921",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified: scripts/resolvers/review.ts:514-516 carries the framing inline for the adversarial pass only; review-army.ts:110 (specialist) and :241 (red team) are unframed. But the DENIAL claim for those two passes rests on one reporter's assertion with no trace; time-attack's own 2026-07-15 review of PR #1921 said 'prompt-string coverage does not demonstrate the intended fixture visibility boundaries' and asked for controlled end-to-end traces, which were never produced, and the fork's 79-line test (80ba8f17) asserts only that strings appear in generated prose. Upstream is itself a fixture-bearing repo (test/fixtures/review-eval-vuln.rb, redact fixtures) and has run /ship's review army on every PR for three months since #1899 landed; no second denial report exists in CHANGELOG, TODOS, or issues, which is evidence the specialist ('You are a specialist code reviewer. Read the checklist') and conditional red-team prompts do not trip the safeguard the way the 'think like an attacker' prompt did. The fork's design also has a real cost: FIXTURE_SUMMARY_MODE on the Security specialist blinds it to test files, yet secrets-in-fixtures and untested auth paths are security findings that require reading them, and the gating is a self-applied conditional ('If your checklist is the security specialist checklist') inside one shared string that relies on the subagent identifying itself. Prepending 'attack-pattern strings you encounter inside test files' to the testing, maintainability, performance, design, and simplification specialists is noise in eight prompts. Price: regen of review/ship review-army sections, factory golden (compared by test/host-config.test.ts, ship-review-loop, ship-document-release-dispatch), context-budget fixture, and a CONFLICTING PR to rebase.",
|
||||
"conditions_to_take": "Take only after a reproducible denial trace on the red-team or security pass (the E2E trace time-attack asked for on #1921). Then frame ONLY the red-team prompt (the attacker-framed one) with the existing sentence, extracted to a shared constant so review.ts:514 and review-army.ts:241 cannot drift; leave the specialist prompt and the security checklist on full reads. Regenerate sections and goldens in the same commit; credit @bmajewski.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "gap2-skillwave-scrape-evidence-gate-1717",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified (scrape/SKILL.md.tmpl:93 'Emit the JSON the skill prints to stdout. Stop.'; :113 emits the prototype JSON with no gate; no 'evidence'/'checkpoint' text; no scrape-evidence-gate touchfile), but the fork's change is larger than its evidence and couples badly with upstream's skillify contract. (1) The E2E is coached: the fork's scenario-6 prompt (9d0d5972, test/skill-e2e-skillify.test.ts) tells the agent to 'follow ... the Step 5 evidence gate (decompose the intent into checkpoints and back each with evidence from the page)', so it tests prompt-following, not whether the SKILL.md produces the behavior; its assertion (surface mentions 'price' plus 'missing/not found/n/a') would already pass under upstream's existing 'When the prototype fails ... Report what you tried ... Do NOT write a partial result and call it done' (scrape/SKILL.md.tmpl:127-137), and it counts error_max_turns as success. (2) Skillify coupling the fork never addressed: skillify/SKILL.md.tmpl:122 synthesizes script.ts from 'only the final-attempt $B calls that produced the JSON'; the gate appends `$B snapshot --text` / `$B screenshot` evidence captures AFTER the JSON is assembled, making them the final $B calls and risking screenshots in the generated script or a broken 'final attempt' slice, i.e. a regression risk to the gate-tier skillify-happy-path. (3) The match-path 'fall through to prototype when the result does not cover the intent' has no objective criterion, so a correct-but-small 200ms result can be discarded for a 30s re-prototype, defeating the match path. (4) 'Be harsh: empty items:[] passes only if confirmed from a capture' mandates an extra round trip on a tier-1 speed skill. The genuine delta over upstream (a filter or ranking named in the intent must be demonstrated by the returned rows) is two sentences, not a 40-line step, a renumber, a touchfile, a tier entry, and a paid periodic E2E. PR #1717 is CONFLICTING and untouched since 2026-05-26.",
|
||||
"conditions_to_take": "Fold two sentences into Step 4 / 'When the prototype fails' (e.g. 'If the intent names a filter, count, or ordering, the rows you return must demonstrate it: filter and sort them yourself, never assert an order you did not apply; a field the page does not have is a prototype failure, not a null'), with evidence captures taken BEFORE assembling the JSON so skillify's final-attempt slice is unchanged. No new step, no match-path fall-through, no E2E scenario; re-capture scrape in test/fixtures/context-budget.json. Credit @orendi84 and close #1717.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-base-detection-local-only",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Upstream's terminal 'fall back to `main`' is a pinned design choice, not an oversight: test/gen-skill-docs.test.ts:649-651 asserts `/fall\\s*back\\s+to\\s+`main`/` on the rendered ship skill, so the fork's tripwire (BANNED regex incl. `fall back to \\`main\\``) would fail an upstream test by construction. The fork's ladder (0aca1f77:skills/ship/references/BASE-DETECTION.md) also inserts `git remote set-head <remote> -a` \u2014 a network round-trip (and, over ssh, a possible passphrase prompt that `2>/dev/null` does not suppress) into a Step 0 that runs at every /ship, /review, /qa start \u2014 and rung 5's 'nearest merge-base among local branches' silently guesses a base on any repo with two local long-lived branches (develop + main), the exact 'silent substitute' rung 1 forbids. The fork verified none of this: test/skill-base-detection.test.ts is a regex scan of rendered prose; there is no fixture test on a `git init` repo. Cost/value is poor: 27 `origin/<base>` consumer sites across 8 templates + 3 resolvers + goldens + all generated SKILL.md, for a user population (local-only repos) where /ship's push+PR pipeline is meaningless by definition and /review's 'on the base branch \u2192 nothing to review' (review/SKILL.md.tmpl:43-45) is a defensible answer for a single-branch repo. The one skill where local-only genuinely matters already handles it: retro/SKILL.md.tmpl:98-100 (RETRO_FETCH failed \u2192 proceed) and bin/gstack-retro-metrics GUARD_REMOTE. The only real bugs the record surfaces are upstream-native, not fork-derived: literal `main` bypassing BASE_BRANCH_DETECT at scripts/resolvers/utility.ts:111-112 and design.ts:83 \u2014 a 3-line fix that needs no port.",
|
||||
"conditions_to_take": "Do not port the ladder. Instead: (1) fix the three literal-`main` sites (utility.ts:111-112 /qa diff-aware mode, design.ts:83) to use the detected base \u2014 an upstream bug, one commit; (2) if field reports of `trunk`/`develop`/no-remote hard stops accumulate, extend generateBaseBranchDetect with a 'no remote \u2192 say so in one line and scope /review to `git diff HEAD` + `git status --porcelain`' terminal state, add `init.defaultBranch` before the literal-main fallback, and update the test at gen-skill-docs.test.ts:649 deliberately; (3) never include `git remote set-head -a` or a merge-base guess in a detection step; (4) require a fixture test that runs the resolver's shell on a `git init` repo with `init.defaultBranch=trunk` and no remote before touching the 27 consumer sites.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-taste-direction-aware-confidence",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute the fix; only the framing. Verified live at HEAD: bin/gstack-taste-update:218-220 passes `bucket = profile.dimensions[dim][action]` into bumpPref, so a rejected-bucket entry only ever receives 'rejected' bumps, its approved_count stays 0, and :201 `confidence = approved_count/(total+1)` pins it at 0 forever; cmdShow's rejected sort at :243 (`confidence * rejected_count`) is therefore a no-op and the drift warning at :204 (`opp.confidence >= 0.6`) is unreachable when the opposite bucket is rejections \u2014 upstream's own test at test/taste-engine.test.ts:269-270 hand-seeds confidence 0.8 to dodge exactly this. The one-line direction-aware fix is arithmetically correct and leaves approved-bucket math unchanged. The only refutation that holds is 'nothing here is FROM the fork': fork 944dcab2 is a strict subset of upstream's open PR #1777 (@jbetala7, verified state OPEN, mergeable MERGEABLE, mergeStateStatus BLOCKED = awaiting review, files bin/gstack-taste-update + test/taste-engine.test.ts). User value is modest (design-shotgun taste weighting), so this is a P2 housekeeping merge, not a P1.",
|
||||
"conditions_to_take": "Merge upstream PR #1777 directly (rebase: target line moved 196\u2192201), credit `Contributed by @jbetala7`, close #1776; do not cherry-pick from the fork. Optional completeness: recompute `confidence = own_count/(total+1)` per bucket in load()/applyDecay so profiles already on disk with rejected confidence 0 heal without waiting for the next bump. Run test/taste-engine.test.ts; the hand-seeded drift test keeps passing.",
|
||||
"verdict_confidence": 9
|
||||
},
|
||||
{
|
||||
"id": "s3-judgment-time-constraint-caps-scale",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The fork's clause 11 \u2014 'Skip or compress optional phases that do not fit it' (0aca1f77:skills/plan/references/SHARED-JUDGMENT.md:15) \u2014 is in direct contradiction with an upstream invariant already in the same prompt space: autoplan/SKILL.md.tmpl:150-159 'You MUST NOT: Compress a review section into a one-liner table row \u2026 Skip a section because it doesn't apply \u2026 \"Skipped\" is never valid for a non-skip-listed section', and ETHOS's completeness principle. Injecting it as a tier\u22652 preamble directive would put two opposing instructions into 41 skills. The 'ceiling' half (a6bc2de3:skills/plan/SKILL.md:51 \u2014 caps at `session`/`hobby`) has no referent upstream: it depends on the fifteen-vector scale system, and upstream's CEO review already ruled on that fork design \u2014 docs/designs/PACING_UPDATES_V0.md:97-110 folds ONLY the chain-scoped accounting into PACING V1.1 and says 'Do NOT adopt the fork's 5/8/12 numeric constants \u2014 the fork itself later replaced them with a zero-default autonomy dial'. Handoff-carried budgets are therefore already owned by a P0 design (TODOS.md 'PACING_UPDATES_V0 \u2014 V1.1'); landing a standalone preamble sentence now pre-empts that design and adds bytes to every tier-2+ skill immediately after an A/B-gated \u2212236 B AUQ slim (CHANGELOG.md:29) with a context-budget ratchet in force. The evidence for the failure is fork-internal: the 18-AUQ-round hackathon run (d36338b5) happened in the fork's own /plan module chain with no Scale header; no upstream reproduction exists, office-hours already routes 'Hackathon / demo \u2014 time-boxed' to the lighter Builder mode (office-hours/SKILL.md.tmpl:106,113), and the fork's pr-886 regression fixture does not even resolve at 0aca1f77:evals/parity/regressions/pr-886.json, so nothing pins the rule's effect.",
|
||||
"conditions_to_take": "Take the IDEA, not the clause, and only inside PACING V1.1: add 'a user-stated time box is one accounting input \u2014 handoffs carry it, chained reviews never re-expand it, and it caps question ROUNDS, not review completeness' to docs/designs/PACING_UPDATES_V0.md's fold-in section. Capture the time box in office-hours Phase 1 (:100-113) and name it in the design-and-handoff block as one line each. Never word it as 'skip or compress phases'; keep autoplan's MUST NOT list authoritative. Any preamble byte growth must carry a context-budget fixture recapture and, like the slim, an A/B receipt.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s7-docs-method-regression-pr2370-stdin-prompt-dispatch",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The port as specified (`_gstack_codex_timeout_wrapper 330 codex exec \u2026 - < \"$_PROMPT_FILE\"`, fork 0aca1f77:skills/review/references/legacy/codex.md:253) silently dispatches an EMPTY prompt on stock macOS. Upstream's wrapper (bin/gstack-codex-probe:177-212) falls back \u2014 when neither gtimeout nor timeout is on PATH, i.e. every Mac without Homebrew coreutils \u2014 to a bash-native watchdog that runs `\"$@\" &`. Bash redirects an asynchronous command's stdin from /dev/null when job control is off and the command itself carries no explicit redirection; a redirect on the enclosing function call does not count. Verified empirically here (bash 5.2.15, non-interactive): `f() { \"$@\" & wait $!; }; f cat < file` captures nothing. So `codex exec -` reads EOF immediately and runs with no instructions \u2014 output that looks exactly like the 'model stall' the fork claims to eliminate, on the platform the watchdog exists for. The gtimeout/timeout path is fine, which is why the fork never noticed. Beyond that defect, the exposure is narrower than the record implies: the default `/codex review` path is `codex review --base` with no prompt argument at all (review.ts:573); only the custom-instructions path (codex/sections/review-mode.md.tmpl:87) and the office-hours second opinion (review.ts:373) inline a file, and the Linux limit is 128 KB per single argument, so it takes a >128 KB branch diff on the custom path to hit E2BIG. Upstream's #2370 covered mktemp only; no upstream report of the argv failure exists. The fork's only test is a regex tripwire (test/gstack2-judgment-surface.test.ts:76).",
|
||||
"conditions_to_take": "Take only after fixing the wrapper: change bin/gstack-codex-probe:194 to `\"$@\" <&0 &` (explicit redirect defeats bash's /dev/null rule) or pass the prompt path to the wrapper and redirect inside, and add a free test that masks timeout/gtimeout from PATH, runs the wrapper with a stdin file, and asserts the child received the bytes. Then convert exactly two sites \u2014 review-mode.md.tmpl:87 and review.ts:373 \u2014 to `codex exec [flags] - < \"$FILE\"`, keep `< /dev/null` at every positional-prompt site (#972 deadlock, CHANGELOG.md:7886), verify `-` on the oldest codex version the probe accepts (0.147.0 confirmed via `codex exec --help`), and port the `$(cat` tripwire into test/gen-skill-docs.test.ts.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-setup-deploy-render-key-bytes",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Cannot refute an 8-line change that removes an instruction to print credential bytes; but the panel's 'P1 security' framing does not survive scrutiny and nothing here comes from the fork. Verified: setup-deploy/SKILL.md.tmpl:104 still reads `echo $RENDER_API_KEY | head -c 4`, and :223 only forbids printing FULL keys. Upstream PR #1096 (@JiayuuWang, verified OPEN, MERGEABLE, last touched 2026-07-15) is byte-identical to fork fc8dded2 \u2014 `gh pr diff 1096` shows the same `[ -n \"${RENDER_API_KEY:-}\" ]` block in both .tmpl and generated SKILL.md. Severity: Render API keys are, to my knowledge, issued with a fixed `rnd_` prefix (Render's API docs do not state the format \u2014 unverified), which would make the four echoed bytes carry zero secret entropy; the line is also prose an agent may or may not execute, and Render is the only provider step that echoes anything (grep `echo $` in the template \u2192 one hit). So this is P3 hygiene with an open, mergeable upstream PR \u2014 not a fork port and not a P1. The fork's extras are net negative: PR #8's executable test spawns bash on a regex-extracted snippet (windows-lane hazard for an 8-line prose change) and its tree-wide 'no credential echo' regex would false-positive on legitimate `head -c` uses (test/evidence.test.ts:117, test/setup-codesign.test.ts:77).",
|
||||
"conditions_to_take": "Merge upstream PR #1096 as-is with `Contributed by @JiayuuWang`, regen SKILL.md, close #1078. Optionally tighten :223 to 'never print any bytes of a credential \u2014 presence via `[ -n ]`, validity via a non-mutating API call'. Skip the fork's executable test and tripwire; if a guard is wanted, scope a regex to `*/SKILL.md` lines piping `$*_KEY|TOKEN|SECRET` into head/cut.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "gap5-hygiene-ios-qa-local-package-wording-1735",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The fork has nothing tested or current to port here. Upstream superseded the substance of e676a910 with a different mechanism \u2014 bin/gstack-ios-qa-regen renders the local package (ios-qa/SKILL.md.tmpl:128-139 'Add the generated `DebugBridge` local SPM dependency'; docs/howto-ios-testing-with-gstack.md:52 'Add `DebugBridge/` as a local package dependency') \u2014 so the fork's hunk 2 (a manual template\u2192destination map) would regress the docs. The fork never applied its own patch: docs/gstack-2/SKILL-CONTENT-CHECKLIST/README.md:37 marks item 9 NOT applied and the fork's skills/qa/references/legacy/ios-qa.md still carries the old wording. The residual is three phrases ('install the DebugBridge SPM dependency' at ios-qa:123-124 and :255; 'SPM package/install' in ios-clean:6,42,50) plus ios-clean/SKILL.md.tmpl:82's wrong `.package(url:...DebugBridge...)` \u2014 an upstream-original error the fork reproduced verbatim (bb57306d:83), i.e. an upstream doc bug to fix under #1735, not a fork contribution. Hard-coding the fork's `.package(path: \"DebugBridge\")` line would itself be wrong for the majority case: ios-qa/SKILL.md.tmpl:105 already instructs that for `.xcodeproj`/`.xcworkspace` apps the agent must 'not invent package or target wiring'. The bundled session-cache/bearer-persistence sub-item is a separate design decision upstream already made differently (ios-qa/daemon/src/tunnel-bootstrap.ts:279-308 relaunches the app once), tracked by open #1796/#1975 \u2014 not a wording item.",
|
||||
"conditions_to_take": "Not as a fork port. In an upstream #1735 close-out commit: change ios-qa:124 and :255 to 'generated local DebugBridge package', fix ios-clean:82 to 'remove the local `DebugBridge` package reference (`.package(path:)` in Package.swift, or the Add Local Package entry in Xcode)', add 'local' at ios-clean:6/:42/:50, and add the daemon `--help` branch (#1735 bug 2). Decide bearer persistence separately against #1796 with the relaunch trade-off stated.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "gap2-skillwave-askuserquestion-compact-payload-1241",
|
||||
"refuted": true,
|
||||
"strongest_argument": "This is not an additive rule; it changes upstream's AskUserQuestion contract and breaks the hooks that read it. Upstream's format (scripts/resolvers/preamble/generate-ask-user-format.ts:41-58) puts the whole D<N> brief \u2014 ELI10, stakes, Recommendation, pros/cons, Net \u2014 INTO the tool payload ('must be sent as tool_use, not prose', pinned by test/resolver-ask-user-format.test.ts:225). The fork's f708f508 says `question` is 'one sentence, no newlines, <=80 chars' and the brief goes in markdown before the call. Upstream's hooks read only tool_input: hosts/claude/hooks/question-log-hook.ts:288-313 hashes the question_id from `q.question` + option labels and stores `question_summary = stripped_question.slice(0,200)`; question-preference-hook.ts:404-457 runs `extractRecommended(qText, opts)` and the one-way classifier `classifyQuestion({summary: qText})` (:443) on `q.question`. Shrinking the question to 80 chars strips the ELI10/stakes/recommendation out of the question log that /plan-tune, /retro and the never-ask keyword net depend on, and changes every question_id hash. It also re-legitimizes the exact pattern upstream spent v1.76-v1.78 rooting out \u2014 'write prose, then (maybe) call the tool' \u2014 with a self-check line 'You wrote the brief, then called the tool_use payload' sitting beside 'You are calling the tool, not writing prose'; the AUQ collapse gate suite (test/skill-e2e-auq-*.test.ts) would need re-baselining. Byte growth (~600 B \u00d7 41 tier-2+ skills) lands directly after an A/B-gated \u2212236 B/skill slim (CHANGELOG.md:29) whose gate was 'zero format-element loss'; the fork offers no comparable receipt. PR #1241 is stale (mergeable UNKNOWN, last touched 2026-07-15, no upstream engagement; the fork maintainer's own comment on it is '@16francej Merge this version'), issue #1208 (VSCode panel layout) has no upstream reproduction, and the fork ships two inconsistent cap sets (80 chars/2 tabs vs ~120 chars/12-char headers/4 options).",
|
||||
"conditions_to_take": "Reproduce #1208 on the VSCode host first. If real, decide payload-vs-prose as a deliberate design in the AUQ resolver (not a cherry-pick): keep enough in `question` for question-log-hook/question-preference-hook to log, hash, classify one-way, and detect `(recommended)`; pick one cap set (the 12-char header cap mirrors the real tool schema); run the same live A/B the slim used plus the AUQ collapse gate; recapture test/fixtures/context-budget.json in the same commit; preserve @jbetala7 credit.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-make-pdf-preview-offline-gate",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute. Verified at HEAD: make-pdf/src/orchestrator.ts:361-362 writes `rendered.html` verbatim to the preview path and `opts.allowNetwork` is read only on the generate path (:170); make-pdf/src/render.ts:256-258 deliberately leaves remote `<img src>` for the inliner ('must keep seeing remote <img src> so its blocked-remote placeholder still fires') while preview skips that inliner by design (:331-345); make-pdf/src/cli.ts:197 parses `--allow-network` into PreviewOptions where it is dead. So `$P preview` on a doc with hotlinked images fires one GET per image with no flag passed, while make-pdf/SKILL.md.tmpl promises offline posture with no preview exception and the sanitizer already treats preview as offline for every other vector (srcset, media, CSS url/@import, svg href). The two refutation angles fail: 'the user's browser fetching the user's own doc images is not gstack egress' is answered by the documented promise and the dead flag; 'it hides images the user wants to see' is backwards \u2014 generate blocks the same images, so today's preview shows content the PDF will NOT have, the divergence orchestrator.ts:331-334 explicitly warns against, and the fix improves fidelity. The fork change (8fae162d) is ~30 lines reusing IMG_TAG_RE/SRC_RE (diagram-prepass.ts:574-575) and buildBlockedRemotePlaceholder (:743), no file I/O, with a unit test; no egress receipt is needed because it removes a fetch rather than adding one.",
|
||||
"conditions_to_take": "Take via cherry-pick: keep upstream's `browseClient.PAYLOAD_TMP_DIR` preview path (fork used os.tmpdir()), export blockRemoteImages, gate in preview() unless `opts.allowNetwork === true`, add the fork's unit test plus an orchestrator-level assertion that preview output contains no `<img[^>]*src=\"https?:` for a remote-image fixture, and extend the preview divergence note (:331-345) so the placeholder is not mistaken for a render bug.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "gap3-catalog-trim-external-hosts",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The gate is deliberate, not an oversight, and the routing cost on non-Claude hosts is unmeasured. Upstream's own v1.46 CHANGELOG (CHANGELOG.md ~3703) documents `--catalog-mode=full` as the mode for 'hosts that depend on the legacy fat catalog', and scripts/gen-skill-docs.ts:910-914 plus test/gen-skill-docs.test.ts:2034-2044 ('multiline descriptions preserved in Codex output') pin the opposite of what the fork does. On Codex the frontmatter description is the ONLY routing signal: hosts/codex.ts rewrites CLAUDE.md->AGENTS.md and setup:217-220 offers only an optional agents-digest, so there is no injected 'Skill routing' block like Claude gets. Trimming removes lines such as 'Proactively invoke this skill (do NOT push/PR directly)' from ship's Codex catalog (test/fixtures/golden/codex-ship-SKILL.md:3-8) on a host with no other routing surface. The fork's own maintainer refused this exact change on upstream PR #1972 ('missing validation that shortened external-host descriptions preserve routing... add positive and negative routing tests'), no such eval exists in either tree, and the fork's added test is a CI no-op (reads gitignored ROOT/.agents/skills and early-returns). The savings claim is also unmeasured: bin/gstack-context-bill and test/helpers/skill-census.ts measure the Claude tree only, and Codex already enforces a 1024-char cap (hosts/codex.ts:15-16).",
|
||||
"conditions_to_take": "Take via upstream PR #1972 (preserves hadrien-de-march attribution) only after: (1) a Codex/gpt routing eval with positive and negative cases shows trimmed descriptions still route /ship, /qa, /review etc.; (2) the Codex token bill is actually measured before/after (extend skill-census per host); (3) test/gen-skill-docs.test.ts:2034-2044 is replaced and goldens regenerated; (4) each external host's frontmatter mode (allowlist vs strip) and the openclaw/hermes instruction-tier hosts are eyeballed for the new '## When to invoke' body section.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-readme-update-check-disclosure",
|
||||
"refuted": true,
|
||||
"strongest_argument": "There is nothing fork-originated to port, and the fork's artifact is now inaccurate. fe35723b is a cherry-pick of community PR #1083 whose single line says gstack 'fetches VERSION from raw.githubusercontent.com hourly'; since the SHA-pinned resolver landed, bin/gstack-update-check:235-243 first runs `git ls-remote` against github.com, then GETs raw.githubusercontent.com, with two cache windows (60 min up-to-date, 720 min upgrade-available, :157-168) and a branch-pinned fallback. The fork's own maintainer flagged exactly this on PR #1083 ('describes only an hourly raw GitHub request... Rewrite it to document the current sequence, data and IP exposure, both cache windows, fallback, and the update_check opt-out'). Taking the fork line as-is would add a new inaccuracy to the privacy section. Meanwhile upstream already discloses the hourly check (README.md:61) and the generic receipt bullet (README.md:508) means `gstack-egress list` shows every update-check send with its host. The residual is an upstream doc-housekeeping fix in Garry's voice, not a fork import.",
|
||||
"conditions_to_take": "Write a fresh bullet in the existing Privacy & Telemetry style (README.md:500-508) directly from bin/gstack-update-check: names both hosts (github.com ls-remote, raw.githubusercontent.com), fires with telemetry off, ~hourly cadence, opt-out `gstack-config set update_check false`, receipted in egress.jsonl, skipped in spawned subagent sessions. Credit @burakbengi / close #1081 and #1083. Optionally add update_check to `gstack-egress grants` (bin/gstack-egress:155-190) with its test.",
|
||||
"verdict_confidence": 5
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-browse-click-missing-selector-no-second-wait",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute. Verified at browse/src/write-commands.ts:349-369: after `.click({ timeout: 5000 })` throws, the catch calls `resolved.locator.evaluate(el => el.tagName === 'OPTION')` (or `target.locator(resolved.selector).evaluate(...)`) on the very locator that just timed out. Playwright's locator.evaluate auto-waits for the element to attach, and browse sets no default timeout anywhere (grep DefaultTimeout in browse/src: zero hits), so a missing selector waits the library default (30s) after the 5s click wait, colliding with the CLI's `AbortSignal.timeout(30000)` at browse/src/cli.ts:828 \u2014 the user sees '[browse] Command timed out after 30s' instead of the precise selector error at ~5s. The fork hunk (a84a6e23, ~12 lines: `optionLocator.count().catch(() => 0)`, evaluate only when count === 1) is correct, self-contained, preserves the existing helpful-error test (browse/test/commands.test.ts:513-522 uses a unique option[value=\"admin\"]), and ships a regression test. The only weakness is that Playwright is not installed in this sandbox so the pre-fix >8s timing could not be reproduced here, and the fork's 6500ms assertion inside an 8000ms test timeout is tight for slow CI.",
|
||||
"conditions_to_take": "Cherry-pick only the write-commands.ts catch-block hunk and the 'click on a missing selector does not start a second locator wait' test from a84a6e23 (the rest of that commit is unrelated GStack-2 work). Bump the test's own timeout to 10s while keeping the <6500ms assertion. Reproduce the pre-fix timing once with Playwright installed so the CHANGELOG line is evidence-backed. An equivalent alternative is `evaluate(..., { timeout: 500 })`, but the count() gate is cleaner.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-iosqa-proxy-timeout-hardening",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute the core bug. ios-qa/daemon/src/proxy.ts:57-89 passes `timeout: 30_000` to httpRequest but registers no `req.on('timeout')` handler and never calls `req.destroy()`; per Node's http docs the 'timeout' event 'only notifies that the socket has been idle. The request must be destroyed manually.' There is no outer deadline anywhere (grep setTimeout|requestTimeout|headersTimeout in ios-qa/daemon/src/index.ts: none; grep max-time in ios-qa templates: none), so a suspended app that keeps the CoreDevice TCP route alive but stops servicing HTTP hangs the daemon request indefinitely, and shouldRefreshTunnel's 504 branch (index.ts:245-256) is unreachable for that case. Upstream has zero behavioral tests of proxyToDevice (proxy-classify.test.ts imports only classifyRoute). What IS refutable is a verbatim port: the fork (2c487305) replaces `reject(err)` with `finish(makeError(502,'upstream_error'))`, which would defeat upstream's proxyAttempt (index.ts:213-243) mapping of ECONNRESET/EPIPE/ENETUNREACH/ECONNABORTED to 503 device_disconnected and thus tunnel re-bootstrap; and the same commit bundles an `x-gstack-expected-bundle-id` header that depends on StateServer.swift.template changes.",
|
||||
"conditions_to_take": "Adapt, do not cherry-pick: add the `settled`/finish guard, `req.on('timeout')` -> 504 upstream_timeout then `req.destroy()`, `res.on('aborted')` -> 503, and `opts.timeoutMs`; KEEP `reject(err)` for unmapped codes (or move RECOVERABLE_SOCKET_ERRORS into proxy.ts) so index.ts recovery still fires; leave out the bundleId header and Swift changes. Port the 'suspended/non-responsive app returns a bounded 504' test (40ms timeout, hung local http server) and drop the bundle-header test.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s9-skills-pr-mutations-rest",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The panel's premise is inverted and the residual is masked in the flow that matters. ship/SKILL.md.tmpl:507 states 'Step 18 dispatches the /document-release subagent BEFORE the PR/MR is created or updated in Step 19' \u2014 so inside /ship, document-release either finds no PR yet (its own 'No PR/MR found \u2014 skipping body update' path, release-body.md.tmpl:336) or its edit is immediately superseded by Step 19, which rewrites the whole body including the documentation_section and the title using the #1079 REST fallback (ship/sections/pr-body.md.tmpl:59). The only exposure is a standalone /document-release run against an existing PR on a repo where `gh pr edit` still trips the Projects-classic deprecation, where the outcome is already warn-and-continue with the docs in the commit (:336-337, :384). Issue #1079 is CLOSED by garrytan as fixed; the fork's maintainer themselves asked JonasFocus to rewrite PR #1944; the fork's always-REST primary and durable-effect wrapper (EXTERNAL-EFFECTS.md) were declined / have no upstream runtime; the 'fine-grained tokens' rationale is unverified. Nothing from the fork transfers \u2014 any parity fix is three lines of upstream's own already-reviewed prose.",
|
||||
"conditions_to_take": "Only as upstream housekeeping, not a fork port: if a user reports a standalone /document-release body/title edit failing with the projectCards error, mirror ship/sections/pr-body.md.tmpl:59 into document-release/sections/release-body.md.tmpl:317 and :377 (PATCH from the SAME scanned temp file, after the gstack-redact scan) and extend test/document-skills-redaction.test.ts:27-32 to pin scan-before-PATCH ordering. Close #1944 with credit.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "gap6-s15-hostadv-untrusted-data-authority-rule",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Upstream enforces this property mechanically where attacker text actually enters, and a prose sentence in the always-on preamble is unproven, untested, and contrary to a recorded maintainer decision. Runtime envelopes exist for every browse read including snapshot (browse/src/commands.ts:61-90 wrapUntrustedContent, markHiddenElements), PR/issue bodies (lib/tracker-guard.ts:6,111), the #2441 UNTRUSTED_CONTENT_WARNING (scripts/resolvers/browse.ts:6-23), and doc-sync JSON (ship/sections/pr-body.md.tmpl:32). scripts/resolvers/preamble/generate-evidence-directive.ts:3-12 records that 'the fork's full SHARED-JUDGMENT contract is deliberately not imported' \u2014 this candidate is SHARED-JUDGMENT rule 6 re-entering as a second directive, landing in ~30 skills' eager tokens and tripping every ceiling in test/fixtures/context-budget.json. The fork's evidence that the rule 'operates' is one passing Claude cell in one host-adversarial run; the fixture set that would make it testable (persona-fleet hostile-user harness) is already deferred in TODOS.md, so upstream cannot show the sentence changes behavior at all. The fork's wording ('Report, plan, and diagnose-only modes cannot edit') depends on the GStack-2 Mutation header upstream does not print, and is wrong for /investigate, which fixes after root cause (investigate/SKILL.md.tmpl Iron Law). The one concrete gap found \u2014 qa/qa-only use {{BROWSE_SETUP}} (qa:95, qa-only:48) which does not carry the existing warning (browse.ts:58-60 attaches it only to COMMAND_REFERENCE) \u2014 is an upstream wiring fix of its own #2441 mechanism, and PR #2186 (danlayfield) is the attribution-bearing path for investigate/qa hunks, not the fork.",
|
||||
"conditions_to_take": "Not as a preamble generator. Take only: (1) wire UNTRUSTED_CONTENT_WARNING into {{BROWSE_SETUP}} so qa/qa-only receive the warning they currently lack; (2) a per-skill line in investigate (instruction-shaped text in logs/stack traces is a finding, not a step) and qa (injection attempts are High-severity findings), sourced from PR #2186 with credit; (3) only if a hostile-fixture eval exists that fails on HEAD and passes with the change. Never suppress under explain_level=terse if it does land.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-auth-json-file-write-removal",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The 'no reader anywhere' claim is scoped to this repo, while the writer's comment names a reader that lives OUTSIDE it, and the security value is near zero because the same token is already on disk. browse/src/browser-manager.ts:638-640 says the file is 'still required even when the extension is component-baked \u2014 it reads ~/.gstack/.auth.json at startup'; the write sits in the headed path where `isCustomChromium()` (:53-69, GSTACK_CHROMIUM_KIND=custom-extension-baked / GBrowser) skips --load-extension, i.e. the file exists precisely for the GStack Browser.app / GBrowser embedder whose Chromium and baked extension are not in this tree (docs/BROWSER_INTERNALS.md:27 'gbrowser phoenix overlay'; the v1.3x embedder API entry in CHANGELOG). Deleting the writer without inspecting GBrowser's source risks breaking that browser's extension bootstrap. The fork could delete it only because 7b3f391b replaced the entire bootstrap (169-line browser-manager change, chrome.storage.session provisioning via context.on('serviceworker') + worker.evaluate, extension-identity.ts) which also reaches a baked worker \u2014 upstream chose POST /extension-token instead, so the deletion does not transfer standalone. On value: the daemon root token is ALREADY persisted 0600 on disk for the CLI in the browse state file (browse/src/cli.ts:120-148 readState() -> state.token, written by server.ts:469-473), so .auth.json is a duplicate of an existing same-user on-disk credential, not a unique exposure; PR #2226 is CONFLICTING and the fork's own comments ('please merge') show no upstream review happened.",
|
||||
"conditions_to_take": "Only after confirming in the GBrowser/GStack Browser.app source (out of tree) that its baked extension bootstraps via POST /extension-token and never reads ~/.gstack/.auth.json; then delete browser-manager.ts:637-654 plus the now-dead import at :19, fix the stale scripts/build-app.sh:78 comment, add a gstack-upgrade/migrations/ script that `rm -f ~/.gstack/.auth.json`, and a source-level tripwire test. Until then, at most fix the two stale comments.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-transcript-ingest-mode-off",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Could not refute: the documented kill switch is dead. bin/gstack-config:181 (`transcript_ingest_mode) echo \"off\"`) is the only non-prose occurrence in the repo; bin/gstack-memory-ingest.ts:541-545 gates transcripts solely on `ctx.args.sources.has(\"transcript\")` (default = ALL_TYPES at :234), and bin/gstack-gbrain-sync.ts runMemoryIngest (:1211-1265) skips only on dry-run, bad engine, or --no-memory/--code-only. Yet setup-gbrain/memory.md:138 advertises `gstack-config set transcript_ingest_mode off` as 'Disable entirely' and transcript-gate.md.tmpl:48-53 offers option E 'Never ingest transcripts (off)' and then runs `gbrain-sync --full` unconditionally. That is a consent promise the code does not keep, and per-repo trust policy (#2392) is a different axis (repo tier), not a global off. The fork fix (6249074d / /tmp/ta-wt/memory-trust:bin/gstack-memory-ingest.ts:523-538) is ~15 lines with a test that asserts the fake gbrain is never spawned. The refutable parts are implementation details: it regex-parses config.yaml directly instead of using gstack-config (because gstack-config's default is 'off', which would silently stop ingest for every install that set up gbrain before the key existed), and PR #2232 as a whole is CONFLICTING and TODOS-gated on the never-double-store review \u2014 but this slice removes writes and is independently shippable.",
|
||||
"conditions_to_take": "Adapt, not cherry-pick: decide the semantics explicitly (recommended: honor only an explicit `off`; either change bin/gstack-config:181 default to empty and update test/gstack-config-defaults.test.ts:139, or read the raw key as the fork does with a comment explaining why); exempt --probe so setup-gbrain's sizing step keeps working (fork PR #7 variant); emit one stderr line when the skip fires so gbrain-sync's stage summary explains the empty corpus; fix transcript-gate.md.tmpl:52 so `<choice>` maps A/B/C to a defined value and option E does not run --full; port the 'honors off mode before invoking gbrain' test; regen setup-gbrain/SKILL.md.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s2-runtime-gen-skill-docs-single-host-fatal",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Premise re-verified: scripts/gen-skill-docs.ts:1150 still reads `if (failures.length > 0 && HOST_ARG_VAL === 'all') return 1;` and a single-host exception falls through to `return 0` (:1210). But the exception class it would catch is nearly empty at install time: the throws inside main()'s per-host try (:732 'Unknown placeholder', :750 'Unresolved placeholders', :568/:706) are deterministic from committed templates and already fail CI's `--host all` (skill-docs.yml) before they can reach a user; the runtime-variable inputs (`--host`, `--model` from setup's CODEX_GENERATION_MODEL) are validated at module top level OUTSIDE main() (:19-51, :87, :105) and already exit nonzero. Meanwhile the flip is not a one-token change: setup:3 is `set -e` and setup:727-731 runs `gen:skill-docs --host codex` UNCONDITIONALLY for every user (`NEEDS_AGENTS_GEN=1`, 'Always regenerate') inside a bare `( ... )` subshell, so a Codex-tree render exception would abort the Claude install for everyone \u2014 the opposite of upstream's deliberate 484cf1fb design ('fault-tolerant per-host error handling') and of the v1.78 'upgrade path can't eat installs' direction. The fork (b6572ebb) added no test and used top-level process.exit, which no longer matches upstream's main()/return-code refactor (b1485d88). Net: a correctness nicety whose realistic trigger is a corrupted checkout, priced as P1 but carrying a setup-polarity decision the panel itself flags.",
|
||||
"conditions_to_take": "Take as a P3 hygiene item only when bundled with (1) an explicit setup decision \u2014 wrap setup:730/740/750/760 so the codex/factory/opencode/cursor renders warn-and-continue for hosts the user did not select and abort only for the host they asked for; (2) a regression test that injects a throwing template/resolver under `--host codex` and asserts exit 1 (the fork never wrote one); (3) an exit-code check at setup:1145-1152 instead of the `[ ! -d ]` probe. Do not cherry-pick b6572ebb.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s2-runtime-question-pref-hook-slug-mismatch",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Cannot refute; the bug is worse than the record states. Verified: hosts/claude/hooks/question-preference-hook.ts:300-306 `slugFromCwd` returns `path.basename(cwd)` and :395 feeds it to `lookupPreference` (:149-159) which reads `projects/<slug>/question-preferences.json`; the only writer, bin/gstack-question-preference:36-38, buckets by `eval \"$(gstack-slug)\"` which is `owner-repo` for any repo with an origin remote (bin/gstack-slug:17-20). Additionally, grep for `global-question-preferences` across bin/, scripts/, *.tmpl finds NO writer \u2014 only the hook reads it (:152) and the test seeds it (test/question-preference-hook.test.ts:59). So in production the Layer 2 PreToolUse deny can only ever hit via the project bucket, which misses for every remote-bearing repo: the shipped enforcement hook is effectively inert outside the no-.git test fixture (:38-41). The best counter-argument \u2014 Layer 1 (`gstack-question-preference --check`, do_check :52+, via gstack-slug) already honors project prefs so users mostly see correct behavior \u2014 does not excuse a documented enforcement layer that never fires. The fork's runtime/identity.js mechanism is not portable, but upstream already owns the fix ingredient: lib/bin-context.ts:150 `slugFromEnvironment(gstackHome, cwd)` is git-spawn-free on cache hits (:163-166) and writes the same slug-cache file as gstack-slug, so reader and writer converge; the hook runs as a bash shim over bun (hosts/claude/hooks/question-preference-hook:1-5) and already imports from ../../../lib (:47), so the import pattern exists.",
|
||||
"conditions_to_take": "Replace slugFromCwd's body with slugFromEnvironment(stateRoot(), stdin.cwd) keeping `if (!cwd) return 'unknown'` (matches the writer's `SLUG=\"${SLUG:-unknown}\"`); rewrite the :301-303 'too expensive' comment. Add a second fixture that pre-seeds `<stateRoot>/slug-cache/<cwd-key>` with `owner-repo`, writes a project pref under projects/owner-repo/, and asserts a deny (no git needed in test); keep the basename fixture. Measure first-miss cost inside the 3s hook budget and confirm the windows-free-tests lane still passes (bin-context.ts toMsysPath path). Re-implement, do not cherry-pick b6572ebb.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s6-branches-browse-record-cdp-screencast",
|
||||
"refuted": true,
|
||||
"strongest_argument": "MISSING upstream is confirmed (browse/src/commands.ts META_COMMANDS has no 'record'; repo-wide grep for startScreencast/recordVideo = 0), but the fork implementation (76c89d56, /tmp/ta-wt/qa-recording) has defects that make 'adapt nearly verbatim' wrong, and the maintainer has already queued a competing design. Defects read from the fork source: (1) the commit adds 'record' to SCOPE_READ (token-registry.ts diff; test asserts `SCOPE_READ.has('record')`), so a read-only pair-agent token can start a continuous on-disk capture of whatever is on screen \u2014 contradicting the module's own header ('a continuous capture is a larger exfil surface than a single screenshot'); #2497 puts record in SCOPE_CONTROL with a pinning test. (2) screencast-encode.ts runFfmpeg/ffmpegAvailable use `spawnSync('ffmpeg', ..., timeout: 120_000)` inside the daemon's request path \u2014 the single-threaded browse daemon (SSE, sidebar, every other tab and CLI call) freezes for up to two minutes per `record stop`; upstream browse code explicitly avoids blocking spawns in handlers (browser-skill-commands.ts:245). (3) The no-ffmpeg fallback writes player.html INTO the frame dir and never removes the frames, so full-resolution JPEGs of login flows persist in TEMP_DIR indefinitely (0700 via mkdirSecure, but no cleanup path or documentation). (4) The 15-min cap is evaluated only inside onFrame (screencast.ts:151-155) \u2014 no wall-clock timer \u2014 and the libvpx-vp9 fallback writes VP9 into the caller's .mp4 filename. (5) browse/test/screencast.test.ts launches a real Chromium and lands in the free suite. Upstream's TODOS.md:43 already names OPEN PR #2497 (20 tests, native WebM via recordVideo, control scope, degraded-flush reporting, author offered to add caps) as 'browse record \u2014 best of the batch'; taking the fork's module now would pre-empt that triage decision rather than inform it. The fork's genuine advantages (no context rebuild, works --headed, no-ffmpeg fallback) are design inputs, not a drop-in.",
|
||||
"conditions_to_take": "Decide at #2497/#1483 triage time with the fork's CDP-screencast design as the alternative. If the CDP approach wins: put 'record' in SCOPE_CONTROL (never READ), keep it off TUNNEL_COMMANDS, spawn ffmpeg asynchronously (or encode in a child process), add a wall-clock stop timer plus a byte budget, use .webm for the vp9 attempt, define frame-dir cleanup for the HTML-player path, make QA/ship prose treat recordings as local evidence the user attaches by hand (redaction scans text, not video), and credit @Topherhindman and @itstimwhite. Land it as the resolution of the queue item, not in addition to it.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s6-branches-record-caps-and-fallbacks",
|
||||
"refuted": true,
|
||||
"strongest_argument": "This is not a portable item on its own: it is ~40 lines of constants and branches inside a recording primitive upstream does not have (browse/src has no screencast code), so there is nothing to port until the record decision is made (see s6-branches-browse-record-cdp-screencast). Judged as a spec, the fork's version is also not what its title claims: MAX_DURATION_MS is checked only when a screencast frame ARRIVES (screencast.ts:151-155), so a static or backgrounded page never auto-stops; MAX_FRAMES=7200 is a frame count, not a byte budget (7200 quality-60 JPEGs at a large viewport is hundreds of MB); the encoder chain writes VP9 into the .mp4 path; and the encode itself runs via spawnSync in the daemon. The panel concedes all three corrections are required ('add a wall-clock setTimeout', 'add a byte ceiling', 'use .webm'), which means the deliverable is upstream-authored requirements, not fork code. The one clean idea \u2014 take a single page.screenshot when zero frames arrived so `stop` always yields an artifact \u2014 is a two-line requirement. #2497's author already offered to add a cap on request, so the requirements can be attached to whichever implementation wins.",
|
||||
"conditions_to_take": "Fold into the record command's acceptance criteria rather than tracking separately: wall-clock stop timer (not frame-driven), byte ceiling alongside a frame ceiling, zero-frame screenshot fallback, ack every CDP frame including dropped ones (with the comment explaining Chromium stalls otherwise), correct container per codec, and tests that inject tiny limits. Document the self-termination behavior in browse/SKILL.md.tmpl so agents know a forgotten `record start` cannot fill the disk.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s7-docs-method-privacy-data-flow-doc",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The premise (no PRIVACY.md/SECURITY.md; README.md:500-513 covers telemetry + receipts only) is verified, but what the fork actually offers is thin: /tmp/ta-gstack-full/docs/gstack-2/PRIVACY.md is 217 lines dominated by Context.dev consent semantics, GStack-2 runtime claims ('Six judgment skills', `npx skills update`), and two dated audit narratives (2026-07-28, EGRESS-AUDIT.md) \u2014 the only portable part is the generic three-column table shape, which is not a fork idea so much as a documentation convention. The panel's own risk note concedes 'a privacy page that lies is worse than none' and therefore requires a NEW parity tripwire test plus a `gstack-egress grants` extension to keep the prose honest \u2014 i.e. the port creates a second, hand-maintained source of truth for facts upstream already keeps machine-readable (egress receipts, `gstack-egress grants`, CLAUDE.md:216-231 sink taxonomy, ARCHITECTURE.md:82-160). Upstream's stance is that receipts and the grants CLI ARE the privacy surface; a static page is the kind of contributor-facing artifact that drifts the moment a new sink lands. The one genuine defect surfaced here is not the missing doc but that `gstack-egress grants` enumerates four grants (bin/gstack-egress:155-190; test/gstack-egress-cli.test.ts:111 pins 'the four upstream grants') while bin/gstack-config defines consent-bearing `update_check` (:47), `codex_reviews` (:52) and `pair_agent` (:64, 'remote tunnel consent \u2014 fail-closed') \u2014 that is an upstream accuracy bug worth fixing on its own, and it owes nothing to the fork.",
|
||||
"conditions_to_take": "Fix the grants under-enumeration first (add update_check, codex_reviews, pair_agent, code-intelligence per-repo consent; update the CLI test) \u2014 this is independent of the fork. If a reviewer-facing page is still wanted, write a SHORT table (one row per consent grant, generated or tripwire-tested against `gstack-egress grants --json` so it cannot drift) in README's existing Privacy section or a root PRIVACY.md in Garry's voice; never paste fork prose, and keep the CLAUDE.md caveat that receipts are forensic observability, not a firewall.",
|
||||
"verdict_confidence": 5
|
||||
},
|
||||
{
|
||||
"id": "s7-docs-method-issue-templates-and-labels",
|
||||
"refuted": true,
|
||||
"strongest_argument": "There is nothing to take from the fork: the fork never implemented issue forms or labels either (fork .github/ has no ISSUE_TEMPLATE; fork labels are GitHub defaults) \u2014 the candidate is 22 lines of suggested label names and field lists in fork CONTRIBUTING.md:47-69, half of which are GStack-2 module taxonomy (judgment/workflow/context/adapter/community-pack/2.0-port) and fields no reporter can realistically supply ('root-cause hypothesis', 'proposed disposition', 'replacement-test contract'). Issue forms and a label taxonomy are a maintainer process decision, not a fork port: verified `gh label list` = 12 defaults and 311/311 open issues unlabeled, which is consistent with upstream's actual triage model \u2014 agent-driven waves (CONTRIBUTING.md:531-547) that read issue text through bin/gstack-issue-guard's untrusted envelope, not label queues. Structured forms add reporter friction (the panel already hedges with `blank_issues_enabled: true`) and label creation/sync needs admin `issues: write`, which the fork itself says not to automate. The one concrete upstream defect found while checking \u2014 .github/PULL_REQUEST_TEMPLATE.md:5 points at 'CONTRIBUTING.md \u2192 The evidence bar', a section that does not exist (`grep 'evidence bar' CONTRIBUTING.md` = 0) \u2014 is a five-minute doc fix that owes nothing to the fork's label list.",
|
||||
"conditions_to_take": "Fix the dangling 'The evidence bar' reference in PULL_REQUEST_TEMPLATE.md:5 by writing upstream's own short section (or re-pointing the link). Only if Garry decides he wants label-based triage: a single minimal bug_report.yml (host/OS, gstack VERSION, observed vs expected, minimal repro, redacted transcript pointing at `bin/gstack-redact --from-file`) authored in upstream voice, plus a labels.yml applied manually once \u2014 no label-sync workflow, no fork label names.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s7-docs-method-multi-os-static-tree-gate",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The fork artifact is not portable and its pattern adds no signal: fork .github/workflows/gstack2-gate.yml runs the six-skill GStack-2 inventory test plus `npx skills add . --list` (an installer upstream does not use) and still names test/skill-size-budget.test.ts, which the fork deleted in f19d6cda \u2014 its macOS leg effectively runs one file. More importantly, the fork's actual idea \u2014 run the STATIC tree/size-budget tests on three OSes \u2014 is exactly the set the record itself shows has zero platform dependence (catalog-budget, context-budget-ratchet, skill-size-budget: pure file IO, no process.platform/spawn), so running them on macOS re-tests the same bytes. The genuinely valuable variant (live BSD `mktemp` in test/mktemp-portability.test.ts:50-58, the #2091/#2370 class, darwin-gated keychain branches in test/claude-provider-keychain.test.ts:117-138) is upstream's own gap, already noted by upstream (touchfiles-data.ts:770-776), and carries CI cost the fork did not price: upstream's Linux lanes run on Ubicloud (free-tests.yml:48) which has no macOS, GitHub macOS runners are slow-queued and 10x minute-weighted, and keychain tests on an ephemeral runner need an unlocked login keychain (flake risk). free-tests.yml:16-18 rejects advisory lanes as 'permanent false comfort', so a new lane must be required from day one or not exist \u2014 a decision upstream should make on its own CI roadmap, not as a fork import.",
|
||||
"conditions_to_take": "Treat as an upstream CI decision independent of the fork: if a macOS lane is wanted, add a `--macos-only` curation in scripts/test-free-shards.ts (mirroring WINDOWS_FRAGILE_PATTERNS/KNOWN_WINDOWS_SAFE :119-261, per windows-free-tests.yml's 'single source of truth' rule) limited to the darwin/BSD-sensitive files (mktemp-portability, regression-issue2091-bsd-mktemp, regression-pr1169-mktemp-fallbacks, gstack-paths, keychain), keep it under ~5 minutes, baseline flake for a week, then make it REQUIRED; skip the platform-neutral budget tests entirely.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-browse-userns-sandbox-probe",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Cannot refute the core fix, only its shape. Verified: browse/src/browser-manager.ts:88-99 still checks only win32/GSTACK_CHROMIUM_NO_SANDBOX/CI/CONTAINER/root; repo-wide grep for apparmor/userns/'No usable sandbox' hits only xprotect-heal.ts NEGATIVE_SIGNATURES; #2157 is OPEN and describes the Ubuntu 24.04 LTS default. The strongest counter-arguments are real but insufficient: (a) the fork author himself asked on #2157 (2026-07-15) to close it as resolved by the env var; (b) the user is not hint-less \u2014 Playwright's launch error embeds Chromium's own fatal text ('...disabled unprivileged user namespaces with AppArmor... try using --no-sandbox'), which server.ts:3373 writes to browse-startup-error.log and cli.ts:600 prints verbatim (PR #1562's transcript shows it), though it never names GSTACK_CHROMIUM_NO_SANDBOX; (c) upstream's v1.4x entry (CHANGELOG.md:3960) deliberately chose opt-in 'without changing the default for everyone else' and rebased #1562's 'default Linux to --no-sandbox' down to an env var; (d) the fork's sysctl probe (4a8833cc) pre-emptively disables the sandbox on `apparmor_restrict_unprivileged_userns=1` even where a setuid chrome-sandbox or an AppArmor profile would have let it work, and its warning goes to the daemon's stderr (i.e. browse-daemon.log), so the silent downgrade is invisible in the terminal; (e) the fork's relaunch-once wraps only headless launch(), not the two launchPersistentContext sites (:744, :1821). None of this outweighs a dead daemon on the current Ubuntu LTS default: upstream already has the exact precedent \u2014 launchWithXProtectHeal retries once on a classified signature \u2014 and a relaunch-once on the literal zygote fatal has zero false positives (it only fires after the sandboxed launch has already died with chromiumSandbox previously true), so it never weakens a sandbox that could have worked.",
|
||||
"conditions_to_take": "Take the relaunch-once fallback first, composed INSIDE the doLaunch closure at all three sites (browser-manager.ts:528, :744, :1821) so XProtect heal stays the outer layer and its NEGATIVE_SIGNATURES keep classifying the string; emit the SANDBOX_UNAVAILABLE line once AND surface it in `browse status`/health output; append the GSTACK_CHROMIUM_NO_SANDBOX=1 hint to the startup-error text; keep the env var as the override. Treat the sysctl probe as optional (it only saves one failed launch and adds false-positive downgrade risk); if included, keep 'unknown sysctl = sandbox ON'. Port the fork's unit tests (browse/test/browser-manager-unit.test.ts shape) and extend the static tripwire to all three launch sites. Cite #2157 as closing; #2101 (Playwright refusing to install on ubuntu26.04) is not fixed by this.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s8-codediff-bin-lib-absorbed-batch",
|
||||
"refuted": false,
|
||||
"strongest_argument": "The record is a mis-titled 'absorbed batch' and 12 of its 13 items ARE absorbed (upstream is ahead on verify-gate trust store and code-intelligence policy veto); as a fork PORT there is nothing to take, and the codex sandbox canary is a separate open issue (#1892) with no upstream equivalent and no merged fork code to port. But residual (d) survives every attempt to refute it: `transcript_ingest_mode` has NO code reader upstream \u2014 only the default at bin/gstack-config:181 and setup prose (setup-gbrain/sections/transcript-gate.md.tmpl:47-53) \u2014 and that very prose runs `gstack-gbrain-sync.ts --full --no-brain-sync` right after the user picks 'E) Never ingest transcripts', while bin/gstack-memory-ingest.ts:234 defaults `sources` to ALL_TYPES incl. transcript and bin/gstack-gbrain-sync.ts:1255-1259 passes no `--sources` filter. A user who explicitly declined transcript ingest gets every transcript ingested on every /sync-gbrain. That is a consent defect under CLAUDE.md's own rules and cannot be argued away. What CAN be refuted is porting the fork's implementation: /tmp/ta-wt/memory-trust:bin/gstack-memory-ingest.ts:530-538 reads `$GSTACK_HOME/config.yaml` with a raw regex, bypassing gstack-config's GSTACK_STATE_ROOT/GSTACK_STATE_DIR resolution (bin/gstack-config:20), and PR #2232 is CONFLICTING.",
|
||||
"conditions_to_take": "Fix (d) natively, not as a fork cherry-pick: in bin/gstack-gbrain-sync.ts, read `gstack-config get transcript_ingest_mode` (or the same resolution) and pass memory-ingest's existing `--sources` flag (bin/gstack-memory-ingest.ts:215,257-261) minus `transcript` when the value is `off`; add a test in test/gstack-gbrain-sync*.test.ts asserting the child argv excludes transcript; make setup-gbrain's transcript-gate prose honest about what option E does. Do NOT take the fork's regex reader, the current-repo scoping, or the source-id split under this record (those are the #2232 siblings, gated by TODOS.md:3947's never-double-store review). Handle the codex sandbox canary under issue #1892 on its own merits (a bwrap/namespace stderr match in review/ship's Codex error handling), not as a fork port.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-terminal-ws-extension-id-pin",
|
||||
"refuted": false,
|
||||
"strongest_argument": "Best case against: the pin is defense in depth only. To reach /ws a caller needs an attachToken in validTokens, which is populated only via /pty-session -> /internal/grant (browse/src/server.ts:1940-1958, :527), which requires AUTH_TOKEN, which is released only to Origin === chrome-extension://GSTACK_EXTENSION_ID with a loopback Host (server.ts:1886-1897, tests at browse/test/extension-token.test.ts:122-135, pair-agent-e2e.test.ts:129-138). A foreign extension cannot mint a token today, so the inert Origin pin protects only against a token that already leaked. Also, GStack Browser.app / GBrowser bake the extension in as a component extension (browse/src/browser-manager.ts:56-68, :503-509, :630-639); if that bundle's manifest key ever differs from extension/manifest.json:6, an unconditional pin locks embedders out of the sidebar terminal. And the fork constant ('hjcdllcckghjebjopehjhplcilonljjk', /tmp/ta-wt/auth-leak:browse/src/extension-identity.ts:5) is NOT upstream's ID ('dgbkdbjebeiblbajiilljmhjdpmiglep', server.ts:306) \u2014 copying it would break the real extension. Why this still isn't a refutation: the premise is verified true (terminal-agent.ts:41 env form, :602 gated check, terminal-agent-control.ts spawns the agent with only BROWSE_STATE_FILE/BROWSE_SERVER_PORT/BROWSE_OWNER_PID, so the pin is never armed), the code comment at terminal-agent.ts:583-584 says the Origin gate is 'required, not optional' while the code makes it optional, and the fix is two lines plus three test-site updates (terminal-agent-integration.test.ts:120-128 'abc123', :135 'test-extension-id').",
|
||||
"conditions_to_take": "Take ONLY the pin, not the rest of #2226: (1) import the manifest-derived constant (split GSTACK_EXTENSION_ID into a small browse/src/extension-identity.ts re-exported from server.ts so terminal-agent.ts does not pull in the whole server module); (2) make the /ws Origin compare unconditional -> 403; (3) update the fake-origin test sites to the pinned ID and add a foreign-extension-ID -> 403 case; (4) before merge, confirm the GStack Browser.app / GBrowser baked extension carries the same manifest key (bun browse/scripts/extension-id.ts against the bundled manifest) or gate the strict compare behind the same isCustomExtensionBakedChromium() detection; (5) never copy the fork constant. Whole-local-surface Host check (#1324), bin/gstack-browse-migrate, bin/gstack-browse-repair and the setup steps stay separate decisions.",
|
||||
"verdict_confidence": 6
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-transcript-source-id-isolation",
|
||||
"refuted": true,
|
||||
"strongest_argument": "Four independent problems, any one of which says 'not now'. (1) The fork passes `--source-id <id>` to `gbrain import` (/tmp/ta-wt/memory-trust:bin/gstack-memory-ingest.ts:1483) with no support probe or fallback; upstream's own design note records `gbrain --help` showing `import <dir> [--no-embed]` (docs/designs/SYNC_GBRAIN_BATCH_INGEST.md:151), gbrain's public docs spell every source-targeting flag `--source <id>` (and only entity-identity commands use `--source-id`), and upstream's #2662 pattern (bin/gstack-gbrain-sync.ts:455-470, CHANGELOG.md:538) exists precisely because an unprobed flag either fails the import or is silently ignored. The panel itself flags this as unverified. (2) The headline harm in issue #2140 \u2014 a ~21M-token dream over transcript pages \u2014 is already mitigated upstream: gbrain-sync scopes the cycle with `gbrain dream --source <codeSourceId>` (bin/gstack-gbrain-sync.ts:1394, :1426-1430), so a separate transcript source buys little until gbrain can exclude a source from dream/extract, which nobody has verified. (3) The two-pass split (runScopedIngest, fork :1964-1982) must be re-derived across upstream machinery the fork predates: resume-from-checkpoint (GSTACK_INGEST_RESUME_DIR, bin/gstack-gbrain-sync.ts:1232-1240), the staged<imported reconciliation guard (bin/gstack-memory-ingest.ts:1803-1812, :2316-2330 'Refusing to advance state'), the --include-gitignored retry (:1800-1819), and BulkResult's skipped_policy_readonly/deny counters (:150-165) \u2014 exactly the 'never-double-store review' TODOS.md:3947 gates this on. (4) PR #2232 is CONFLICTING (last touched 2026-09-01 by a rebase, still conflicting), so this is a rewrite, not a port. Whether `gbrain import` even honors the .gbrain-source pin (the routing premise of #2140) is asserted, not shown, in upstream code.",
|
||||
"conditions_to_take": "Take as part of a designed #2140 fix, not a cherry-pick: (a) probe `gbrain import --help` for the real flag (`--source` most likely) and fall back to today's behavior with a warning (the #2662 pattern); (b) confirm empirically whether import routes to the pinned code source from a repo CWD \u2014 if it does not, the isolation is a nice-to-have, not a defect; (c) confirm gbrain can exclude the transcript source from dream/extract, otherwise say so in the issue; (d) do the never-double-store review across resume, reconciliation and the two-pass BulkResult merge; (e) keep the id shape `transcripts-<slug<=11>-<sha8>` and the [a-z0-9-]{1,32} regex test. Sequence it after the transcript_ingest_mode=off consent fix, which is the actual P1 in this area.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-team-hook-cross-platform-cjs",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The premise ('Windows hosts without Git Bash on PATH') is not a real Claude Code population: Claude Code on native Windows REQUIRES Git for Windows, and the actual Windows failure is that its hook runner defaults to cmd.exe, which cannot launch a .sh (anthropics/claude-code#16602) \u2014 the fix for exactly that problem is already upstream convention: the `bash ` prefix (setup:2060-2071 'Prefix with bash so Claude Code's hook runner invokes Git Bash explicitly', bin/gstack-settings-hook:121-186 gsHadBashPrefix/gsQuoteCmd, CHANGELOG.md:662 'Windows gets the required bash prefix on all hooks'). team-init's only defect is that it writes its own settings entry (bin/gstack-team-init:165-195) and skips that convention \u2014 a one-line fix. The fork replacement is worse on four axes: (1) its check-gstack.cjs and its rewritten CLAUDE.md snippet check ONLY ~/.claude/skills/gstack/bin (/tmp/ta-wt/team-enforce diff), re-introducing the #2500 false-block for Codex/Factory/Kiro/OpenCode/Slate/Cursor/OpenClaw/Hermes/gbrain hosts and ~/.gstack/repos/gstack that upstream fixed at bin/gstack-team-init:135-138 and :70-78; (2) it adds a Node-on-PATH dependency to every consumer repo's hook (Claude Code's native installer does not guarantee `node`) and a ~500-char inline `node -e` command in a team-shared settings.json that reviewers must trust; (3) its Copilot rationale is moot for the file team-init writes \u2014 GitHub's docs say Copilot CLI reads hooks from .github/hooks/ and ~/.copilot/hooks/ with camelCase preToolUse, not .claude/settings.json; (4) the README hunk is a voice rewrite (Community PR guardrail #3), the diff also drops BASH_COMPAT=50 (fork predates it), and PR #2229 has been CONFLICTING since 2026-07-15.",
|
||||
"conditions_to_take": "Do not port. Fix the real gap natively: register the team hook command as `bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"` on Windows (or route the registration through `gstack-settings-hook add-event` so it inherits the existing prefix/quoting authority), keep the #2500 11-directory root list, and add a test that the registered command carries the prefix when IS_WINDOWS. Optionally adopt one idea from the fork without its code: emit a distinct 'could not be verified' deny for non-ENOENT stat errors. Revisit a shell-free launcher only if a supported host is shown to run hooks without any shell.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-setup-probe-deadline",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The candidate's headline is absorbed (setup:417-441 _kill_tree/_wait_with_deadline, :444-466 node-first probe with 90s, CHANGELOG.md:1351-1354, issue #2136 CLOSED), and every remaining piece is either superseded, worse than an existing upstream proposal, or a product decision the fork does not get to make. (1) Install bound: fork's default is 300s (gh pr diff 2233 :126), which would kill legitimate ~280MB downloads on slow links; upstream PR #2359 (OPEN, non-fork) proposes 30 min with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT, diagnoses the actual hang (extraction after 100% download) and ships a README manual-fix recipe \u2014 strictly better, so take #2359 not the fork. (2) Probe deadline: fork's 15s is more aggressive than upstream's 90s; a cold first Chromium launch on a laptop can exceed 15s, so the fork value would create false failures. (3) Continue-after-probe-failure (#1902 OPEN): changes setup's exit-code contract so a broken browser install exits 0 and /qa, /browse, /design-review then fail later with cryptic errors \u2014 that is a product call needing an exit-code audit, not a port. (4) Setup-wide .setup.lock.d is superseded by the narrower _PW_LOCK mutex with stale-pid reclaim (setup:785-801). (5) Windows taskkill //T branch is untestable in Linux CI and only matters if MSYS pgrep misses native children \u2014 unverified. (6) The fork's static test asserts fork identifiers (run_with_deadline, GSTACK_PLAYWRIGHT_*_SECONDS) and would be rewritten from scratch. PR #2233 is CONFLICTING against a pre-v1.67 setup.",
|
||||
"conditions_to_take": "Close this candidate as SUPERSEDED. Handle the remainder through the upstream artifacts that already exist: review/merge PR #2359 for the install bound (reuse _wait_with_deadline rather than adding a second helper), decide #1902 (warn-and-continue vs abort) as a product call with an exit-code audit of test/setup*.test.ts and gstack-upgrade, and add one static regression test pinning _kill_tree/_wait_with_deadline/the 90s probe. Then update TODOS.md:3951-3953 to say the remainder was extracted.",
|
||||
"verdict_confidence": 8
|
||||
},
|
||||
{
|
||||
"id": "s6-branches-web-data-task-taxonomy-and-rankings",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The taxonomy is a routing table over providers upstream deliberately does not ship \u2014 grep firecrawl|exa|TASK_RANKINGS across upstream -> 0 \u2014 so five of its six rows point at nothing a user can invoke, and prose that says 'browsers serialize \u2014 wrong tool past a handful of pages' or 'hostile sites -> a provider' nudges users toward off-machine API scraping that upstream would then have to wrap in egress receipts and consent, which is the opposite of the platform-agnostic, first-party stack. Two of the four 'durable' rules are already present or contradicted upstream: the authenticated -> user's-own-browser rule lives in scripts/resolvers/third-party-actions.ts:35-37 and TODOS.md:462-486 already schedules extending it to evidence/scraping 'qualitatively \u2014 never its perishable timing numbers' as part of the Aside Phase 2 product call (deliberately deferred at D1A as 'a separate product call'); the 'browsers serialize' rule is contradicted by upstream's `tab-each` (browse/src/commands.ts:152) which fans a command across every open tab; and anti-bot handling already has concrete first-party guidance (browse/SKILL.md.tmpl:281-298 'Headed Mode + Proxy + Anti-Bot Sites', :257-273 handoff on CAPTCHA/bot detection) that is more actionable than 'pretend it will not work'. What is left \u2014 'search is a different task from scrape' and 'crawl/batch are out of scope' \u2014 is one sentence, already half-present at scrape/SKILL.md.tmpl:143. The fork's own `why` strings and both invariant tests (/tmp/ta-wt/web-data/test/web-data.test.ts:43-49) are about TASK_RANKINGS, which the panel agrees must not port; a test cannot pin prose that does not exist. Porting now also front-runs the Phase 2 decision and costs a scrape eager-token ratchet refresh for a paragraph.",
|
||||
"conditions_to_take": "Take nothing now. When the Aside Phase 2 ({{AGENTIC_BROWSER_FALLBACK}}) work in TODOS.md:462-486 is picked up, fold two sentences into scrape/SKILL.md.tmpl's 'does NOT do' list in Garry's voice: 'search (finding the URLs) is a different task from scraping a known page \u2014 find first, then scrape' and 'multi-page crawls and bulk batches are out of scope; use tab-each for a handful of known URLs, and never send logged-in pages to any third-party service'. No rankings, no vendor names, no timings, no new lib.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s1-prewave-team-hook-matcher-lowercase-skill",
|
||||
"refuted": true,
|
||||
"strongest_argument": "The change is a no-op in every host upstream supports and its only claimed beneficiary does not read the file being edited. In Claude Code the matcher is a regex, so 'Skill|skill' matches exactly what 'Skill' matches \u2014 zero behavior change. The value rests entirely on GitHub Copilot CLI consuming .claude/settings.json PreToolUse hooks with a lowercase `skill` tool name; GitHub's own Copilot CLI hooks documentation lists the hook sources as .github/hooks/ (repo) and ~/.copilot/hooks/ (user) with camelCase `preToolUse`, and a Claude-plugin portability write-up states Copilot CLI does not read .claude/settings.json. Upstream has no Copilot host (ls hosts/: claude codex cursor factory gbrain hermes kiro openclaw opencode slate), cannot reproduce PR #2229's 'Copilot CLI 1.0.70' probe in CI, and the PR has been CONFLICTING since 2026-07-15. Shipping it means either an unverifiable CHANGELOG claim or a silent one-token change plus a dedup branch for two spellings that exists for no supported runtime \u2014 code with no reachable behavior is maintenance, not value. If Copilot support ever lands, its hook file is a different path and event casing, so this hunk would not be the change needed anyway.",
|
||||
"conditions_to_take": "Take only alongside a real Copilot host adapter in hosts/ (or a reproduced demonstration that Copilot CLI honors .claude/settings.json PreToolUse with tool name `skill`), at which point the matcher change is one line inside that adapter's hook registration and the CHANGELOG can say 'hook matcher also covers hosts that report the tool as lowercase skill' honestly.",
|
||||
"verdict_confidence": 7
|
||||
},
|
||||
{
|
||||
"id": "s6-branches-qa-recording-specialist-judgment",
|
||||
"refuted": true,
|
||||
"strongest_argument": "There is nothing for this prose to govern: upstream has no record primitive (browse/src/commands.ts:91-269 has no `record` key, browse/src/token-registry.ts has no record scope, ios-qa/scripts/ has no record-session.ts), and the three candidate primitives are mutually incompatible \u2014 the fork's CDP-screencast JPEG->ffmpeg mp4 recorder (/tmp/ta-wt/qa-recording:browse/src/commands.ts:179, screencast-encode.ts), upstream PR #2497's Playwright recordVideo .webm (CONFLICTING since 2026-08-09, unreviewed, already queued at TODOS.md:43), and PR #1483. The rules are more mechanism-coupled than the panel allows: under #2497, `record start` rebuilds the BrowserContext and invalidates every @e ref (its own table: 'triggers context recreation, invalidates refs'), is headless-only and control-scope, and refuses in headed mode \u2014 so the fork's headline rule 'start recording BEFORE the first interaction, even ahead of qa-only's app detection' would have to become 'goto, record start, then re-snapshot', the 'watch/show me -> headed' row is impossible, and `record stop --open`, `--fps 8`, the mp4 default path, the ffmpeg-optional HTML player, `record status` heartbeat, and 'pair-agent tunnel tokens cannot invoke record' are all fork-surface specifics. What remains mechanism-neutral (screenshots stay the evidence map; stop even on mid-run failure; honest 'RECORDING: <path>' line; never claim the user watched it; no POST /state/* shortcuts during a walkthrough \u2014 the last already present at ios-qa/SKILL.md.tmpl:233-240) is four sentences that should be written once against the primitive that actually lands, in Garry's voice, not ported from a 138-line fork module. Landing prose now would also cost eager-token ratchet refreshes for qa and qa-only (test/fixtures/context-budget.json) for a feature users cannot run.",
|
||||
"conditions_to_take": "Not until a record primitive is merged (the #2497 review already sits in the feature-PR triage queue at TODOS.md:43). Then add a ~15-20 line shared section (one resolver, injected into qa/qa-only and browse \u00a712) written against that primitive's real semantics, carrying only the neutral rules: start capture before the first interaction (and re-snapshot if the primitive invalidates refs), per-finding screenshots remain mandatory, stop-and-report even when QA fails mid-run, never leave a capture running or start a second one, print the artifact path and never claim the user has watched it, no state-write shortcuts during a walkthrough. Pin with a small prose test and refresh the context-budget fixture in the same commit. Separately, fix the dangling ios-qa promise of a 4fps recording (ios-qa/SKILL.md.tmpl:238-240) with either a mechanism or a wording change.",
|
||||
"verdict_confidence": 8
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"raw": 583,
|
||||
"canonical": 415,
|
||||
"residual": 287,
|
||||
"other": 128,
|
||||
"residual_final": {
|
||||
"TAKE_PARTIAL": 142,
|
||||
"TAKE": 127,
|
||||
"SKIP": 6,
|
||||
"RESIDUAL_UNJUDGED": 5,
|
||||
"DEFER": 7
|
||||
},
|
||||
"other_final": {
|
||||
"ABSORBED": 100,
|
||||
"NOT_APPLICABLE": 25,
|
||||
"SUPERSEDED": 3
|
||||
},
|
||||
"panel_priority_among_takeish": {
|
||||
"P0": 4,
|
||||
"P1": 95,
|
||||
"P2": 126,
|
||||
"P3": 44
|
||||
},
|
||||
"refute_batches": 6,
|
||||
"refute_items": 48,
|
||||
"skeptic_flips": 5,
|
||||
"refuters_already_done": 36
|
||||
}
|
||||
@@ -12,6 +12,22 @@
|
||||
# not a boundary.
|
||||
set -euo pipefail
|
||||
|
||||
# Deny-tier backstop: any unexpected non-zero death (a failing pipeline under
|
||||
# set -e, a deleted cwd, EACCES) would otherwise exit with no decision JSON,
|
||||
# which Claude Code treats as non-blocking — the edit proceeds. Every
|
||||
# deliberate output below sets _FREEZE_DECIDED first so a late failure after
|
||||
# a decision never prints a second JSON object.
|
||||
_FREEZE_DECIDED=""
|
||||
_freeze_backstop() {
|
||||
local rc=$?
|
||||
if [ "$rc" -ne 0 ] && [ -z "$_FREEZE_DECIDED" ]; then
|
||||
_FREEZE_DECIDED=1
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Hook failed unexpectedly (exit %s) - blocked, fail closed. Re-run ./setup or /unfreeze."}}\n' "$rc"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
trap _freeze_backstop EXIT
|
||||
|
||||
# Read stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
@@ -27,16 +43,30 @@ _HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# (an if-guard cannot catch it) — the existence check must come first.
|
||||
_HOOK_HELPER="$_HOOK_DIR/../../careful/bin/hook-extract.sh"
|
||||
if [ ! -f "$_HOOK_HELPER" ] || ! . "$_HOOK_HELPER" 2>/dev/null; then
|
||||
_FREEZE_DECIDED=1
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Hook helpers unavailable (broken install?) - blocked, fail closed. Reinstall gstack or run /unfreeze."}}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Locate the freeze directory state file
|
||||
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
|
||||
# Locate the freeze directory state file. The writer (/freeze via
|
||||
# gstack-paths) and this reader MUST resolve the same root or the boundary
|
||||
# fails open: with GSTACK_HOME set, /freeze wrote freeze-dir.txt under
|
||||
# GSTACK_HOME while this hook read $HOME/.gstack, found nothing, and allowed
|
||||
# everything (#1459, #1509). gstack_hook_state_root mirrors gstack-paths.
|
||||
# A helper from an older install that lacks the function must fail CLOSED
|
||||
# (the existence check above only proves the file sourced), never exit 127
|
||||
# with no JSON — Claude Code treats that as non-blocking.
|
||||
if ! command -v gstack_hook_state_root >/dev/null 2>&1; then
|
||||
_FREEZE_DECIDED=1
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Hook helpers out of date (partial upgrade?) - blocked, fail closed. Re-run ./setup or /unfreeze."}}\n'
|
||||
exit 0
|
||||
fi
|
||||
STATE_DIR="$(gstack_hook_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
|
||||
FREEZE_FILE="$STATE_DIR/freeze-dir.txt"
|
||||
|
||||
# If no freeze file exists, allow everything (not yet configured)
|
||||
if [ ! -f "$FREEZE_FILE" ]; then
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -55,6 +85,7 @@ esac
|
||||
|
||||
# If freeze dir is empty, allow
|
||||
if [ -z "$FREEZE_DIR" ]; then
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -69,11 +100,13 @@ set -e
|
||||
# allows what it cannot read is not a boundary.
|
||||
if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
|
||||
gstack_hook_decision deny "[freeze] Could not parse the tool payload to check the freeze boundary. Blocked (fail closed). Freeze boundary: $FREEZE_DIR"
|
||||
_FREEZE_DECIDED=1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parsed fine but no file_path field: a non-file tool payload — allow.
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -119,6 +152,7 @@ FREEZE_DIR=$(_resolve_path "$FREEZE_DIR")
|
||||
case "$FILE_PATH" in
|
||||
"${FREEZE_DIR}/"*|"${FREEZE_DIR}")
|
||||
# Inside freeze boundary — allow
|
||||
_FREEZE_DECIDED=1
|
||||
echo '{}'
|
||||
;;
|
||||
*)
|
||||
@@ -130,5 +164,6 @@ case "$FILE_PATH" in
|
||||
# into hand-built JSON: a path containing a quote or newline produced
|
||||
# malformed JSON here, and the deny silently no-oped.
|
||||
gstack_hook_decision deny "[freeze] Blocked: $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed."
|
||||
_FREEZE_DECIDED=1
|
||||
;;
|
||||
esac
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.80.0",
|
||||
"version": "1.81.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -262,8 +262,8 @@ const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
{
|
||||
file: 'test/setup-windows-rerun-refresh.test.ts',
|
||||
// Trips the "spawns bin/ shebang script" pattern via path.join(..., 'bin',
|
||||
// 'tool.sh') fixture paths, but every spawn goes through spawnSync('bash',
|
||||
// ['-c', ...]) — Git Bash executes it fine on windows-latest. This file IS
|
||||
// 'tool.sh') fixture paths, but every spawn goes through test/helpers/bash-script.ts
|
||||
// (bash <tempfile>) — Git Bash executes it fine on windows-latest, with no argv-length ceiling. This file IS
|
||||
// the #2444 Windows regression coverage (IS_WINDOWS=1 copy-refresh path);
|
||||
// excluding it here would keep the bug class unexercised on the one
|
||||
// platform it bites.
|
||||
|
||||
@@ -116,6 +116,177 @@ _link_or_copy() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Ownership gate for skill entries (#2119) ─────────────────────────────────
|
||||
# setup and gstack-relink must never delete or link over a skill they do not
|
||||
# own. This is the single rule both use (relink carries the same logic — keep
|
||||
# them in sync until the shared helper filed in TODOS.md lands). Proof has two
|
||||
# strengths: STRONG (a symlink resolving into gstack, or the .gstack-owned
|
||||
# marker) means we created the entry and may delete or refresh it whole; WEAK
|
||||
# (byte-identity with our source, or the two-line generated banner on a real
|
||||
# file) covers only that SKILL.md — never the directory — and a differing
|
||||
# weakly-proven file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/
|
||||
# before we install over it. An entry is OURS
|
||||
# when: it is a symlink resolving into the gstack payload / render dir (or any
|
||||
# path with a `gstack` segment, the convention cleanup and gstack-uninstall
|
||||
# already use, so entries from a sibling worktree still count), a real dir
|
||||
# whose SKILL.md is such a symlink, or a real-file copy proven by the
|
||||
# .gstack-owned marker, byte-identity with the source, or gen-skill-docs'
|
||||
# generated header. Anything else is FOREIGN: skipped, reported, listed in
|
||||
# the final summary.
|
||||
_FOREIGN_SKIPPED_ENTRIES=()
|
||||
_gstack_link_target_abs() {
|
||||
# readlink of a relative link is relative to the link's directory; anchor it
|
||||
# there and canonicalize the directory part (`..`, symlinked components).
|
||||
local link="$1" dest d b d_real
|
||||
dest="$(readlink "$link" 2>/dev/null || true)"
|
||||
[ -n "$dest" ] || return 1
|
||||
case "$dest" in /*) ;; *) dest="$(dirname "$link")/$dest" ;; esac
|
||||
d="${dest%/*}"; b="${dest##*/}"
|
||||
if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then printf '%s\n' "$d_real/$b"; else printf '%s\n' "$dest"; fi
|
||||
}
|
||||
_gstack_target_is_ours() {
|
||||
# $1 = absolute target path, $2 = gstack payload dir
|
||||
local t="$1" g="$2" g_real render render_real
|
||||
g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")"
|
||||
render="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
|
||||
render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")"
|
||||
case "$t" in
|
||||
"$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;;
|
||||
esac
|
||||
# A checkout named without a `gstack` segment (git worktree add
|
||||
# ../gstack-<branch>, a ZIP unpacked as gstack-main): the target's skill
|
||||
# root is a gstack tree if it carries setup + VERSION + bin/gstack-relink
|
||||
# (a hand-written skill repo with a VERSION file and a setup script does not).
|
||||
local root="${t%/*/SKILL.md}"
|
||||
if [ "$root" != "$t" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
_claude_entry_is_ours() {
|
||||
# $1 = existing entry (dir or symlink), $2 = the gstack source SKILL.md it
|
||||
# would be linked to, $3 = gstack payload dir
|
||||
local entry="$1" src_md="$2" g="$3" render_md
|
||||
_claude_entry_owned_strongly "$entry" "$g" && return 0
|
||||
# A symlink that did not resolve into gstack is someone else's; never follow
|
||||
# it into the "unclaimed directory" rule below.
|
||||
[ -L "$entry" ] && return 1
|
||||
# No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the user's
|
||||
# other files behind, or it was never a skill). Adding our SKILL.md
|
||||
# overwrites nothing, so installing into it is allowed; the cleanup arms
|
||||
# require a SKILL.md and so never touch it.
|
||||
if [ -d "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then return 0; fi
|
||||
if [ -d "$entry" ] && [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then
|
||||
[ -n "$src_md" ] && [ -f "$src_md" ] && cmp -s "$entry/SKILL.md" "$src_md" && return 0
|
||||
# A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers
|
||||
# it), so an exact copy of that render is ours too.
|
||||
if [ -n "$src_md" ]; then
|
||||
render_md="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md"
|
||||
[ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0
|
||||
fi
|
||||
_gstack_generated_header "$entry/SKILL.md" && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
# _claude_entry_owned_strongly ENTRY GSTACK_DIR — we created it: a symlink into
|
||||
# gstack, or a real dir with the .gstack-owned marker or a SKILL.md symlink
|
||||
# into gstack. Only strong proof authorizes deleting a directory whole.
|
||||
_claude_entry_owned_strongly() {
|
||||
local entry="$1" g="$2" dest
|
||||
if [ -L "$entry" ]; then
|
||||
dest="$(_gstack_link_target_abs "$entry")" || return 1
|
||||
_gstack_target_is_ours "$dest" "$g"; return $?
|
||||
fi
|
||||
[ -d "$entry" ] || return 1
|
||||
[ -f "$entry/.gstack-owned" ] && return 0
|
||||
if [ -L "$entry/SKILL.md" ]; then
|
||||
dest="$(_gstack_link_target_abs "$entry/SKILL.md")" || return 1
|
||||
_gstack_target_is_ours "$dest" "$g"; return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
# Weakly-proven real files we would otherwise overwrite are moved here (mv, so
|
||||
# the path is free for the link); the final summary prints one line.
|
||||
_SKILL_BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)"
|
||||
_BACKED_UP_SKILL_MDS=()
|
||||
_backup_skill_md() {
|
||||
# Returns non-zero when the file could NOT be moved: the caller must then
|
||||
# leave the entry untouched (a failed backup is never a license to overwrite).
|
||||
local file="$1" name="$2"
|
||||
mkdir -p "$_SKILL_BACKUP_ROOT/$name" 2>/dev/null || return 1
|
||||
mv -f "$file" "$_SKILL_BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1
|
||||
_BACKED_UP_SKILL_MDS+=("$name")
|
||||
return 0
|
||||
}
|
||||
# _cleanup_weak_dir DIR — remove only what weak proof covers: the SKILL.md and
|
||||
# our marker. User files in the directory stay, and so does the directory
|
||||
# when it is not empty afterwards.
|
||||
# _cleanup_weak_dir DIR GSTACK_DIR [SRC_SKILL_MD NAME] — remove only what weak
|
||||
# proof covers. A real SKILL.md that differs from our source (raw, or with its
|
||||
# name: line rewritten to NAME, which is how alias and prefixed copies differ)
|
||||
# is a customized file: it is moved to the backup root, never deleted, and if
|
||||
# the backup fails it stays. Our runtime-asset links go; the user's files stay.
|
||||
_cleanup_weak_dir() {
|
||||
local d="$1" g="$2" src="${3:-}" name="${4:-${1##*/}}" e dest
|
||||
if [ -f "$d/SKILL.md" ] && [ ! -L "$d/SKILL.md" ] && [ -n "$src" ] && [ -f "$src" ] \
|
||||
&& ! cmp -s "$d/SKILL.md" "$src" \
|
||||
&& ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: $name/" "$src" | cmp -s - "$d/SKILL.md"; then
|
||||
if ! _backup_skill_md "$d/SKILL.md" "$name"; then
|
||||
echo " kept $name/SKILL.md: could not back up the customized file — left untouched" >&2
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
rm -f "$d/SKILL.md"
|
||||
fi
|
||||
rm -f "$d/.gstack-owned"
|
||||
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
||||
[ -L "$e" ] || continue
|
||||
dest="$(_gstack_link_target_abs "$e")" || continue
|
||||
if _gstack_target_is_ours "$dest" "$g"; then rm -f "$e"; fi
|
||||
done
|
||||
rmdir "$d" 2>/dev/null || echo " cleaned ${d##*/}/SKILL.md (other files in that directory were left in place)"
|
||||
}
|
||||
# _gstack_dir_only_links DIR GSTACK_DIR — true when deleting DIR whole loses
|
||||
# nothing of the user's: every entry is a symlink resolving into gstack, or
|
||||
# our marker. A user's own link (notes.md -> ~/notes) makes the dir mixed.
|
||||
_gstack_dir_only_links() {
|
||||
local d="$1" g="$2" e dest
|
||||
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
||||
{ [ -e "$e" ] || [ -L "$e" ]; } || continue
|
||||
[ "${e##*/}" = ".gstack-owned" ] && continue
|
||||
[ -L "$e" ] || return 1
|
||||
dest="$(_gstack_link_target_abs "$e")" || return 1
|
||||
_gstack_target_is_ours "$dest" "$g" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
# _cleanup_linked_dir DIR GSTACK_DIR — a real dir whose SKILL.md is a symlink
|
||||
# into gstack. Whole-directory removal needs the marker (we created it) or a
|
||||
# directory holding nothing but our links; otherwise only our files go.
|
||||
_cleanup_linked_dir() {
|
||||
if [ -f "$1/.gstack-owned" ] || _gstack_dir_only_links "$1" "$2"; then rm -rf "$1"; else _cleanup_weak_dir "$1" "$2"; fi
|
||||
}
|
||||
# _gstack_generated_header FILE — a pre-marker legacy COPY (Windows, before
|
||||
# .gstack-owned existed) is recognized by gen-skill-docs' full two-line banner
|
||||
# near the top, not by a one-line substring another generator could plausibly
|
||||
# emit. Still forgeable by a gstack fork that renders the same banner — that
|
||||
# residual is accepted and filed; the marker is the load-bearing signal.
|
||||
_gstack_generated_header() {
|
||||
# Bytes, not lines: a long frontmatter pushes the banner past line 40 in
|
||||
# four real skills (investigate: line 57), and a line-count check left them
|
||||
# "foreign" on every pre-marker Windows install.
|
||||
local f="$1" head40
|
||||
head40="$(head -c 8192 "$f" 2>/dev/null)" || return 1
|
||||
case "$head40" in
|
||||
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
_write_owned_marker() {
|
||||
# Windows copy installs have no symlink to readlink; the marker proves
|
||||
# provenance. Records the owning payload's real path for forensics.
|
||||
local dir="$1" g="$2"
|
||||
printf '%s\n' "$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" > "$dir/.gstack-owned" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ─────────
|
||||
# On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host
|
||||
# skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills,
|
||||
@@ -139,8 +310,10 @@ _owned_for_windows_refresh() {
|
||||
# deletes, so a skill removed from the source tree stays rendered — and the
|
||||
# link loops below would re-link it into every host on every run.
|
||||
# $1 = install root, $2 = generated tree, $3 = host skills dir (optional): the
|
||||
# host's entry for a pruned name goes too,
|
||||
# gated by _owned_for_windows_refresh so a user's own dir is never touched.
|
||||
# host's entry for a pruned name goes too, gated by _owned_for_windows_refresh
|
||||
# so a user's own dir is never touched; a bannered REAL directory is cleaned
|
||||
# through _cleanup_weak_dir (our SKILL.md, marker and links only) rather than
|
||||
# deleted whole, because banner-only proof is weak proof (#2119).
|
||||
_prune_stale_generated() {
|
||||
local gstack_dir="$1" gen_dir="$2" skills_dir="${3:-}" d n
|
||||
for d in "$gen_dir"/gstack-*/; do
|
||||
@@ -150,7 +323,17 @@ _prune_stale_generated() {
|
||||
[ -f "$gstack_dir/$n/SKILL.md.tmpl" ] && continue
|
||||
rm -rf "$d"
|
||||
if [ -n "$skills_dir" ] && { [ -e "$skills_dir/$n" ] || [ -L "$skills_dir/$n" ]; } && _owned_for_windows_refresh "$skills_dir/$n"; then
|
||||
rm -rf "$skills_dir/$n"
|
||||
if [ -L "$skills_dir/$n" ] || [ ! -d "$skills_dir/$n" ]; then
|
||||
# A symlink into our render tree, or a stray file: removing it destroys
|
||||
# nothing of the user's.
|
||||
rm -rf "$skills_dir/$n"
|
||||
else
|
||||
# A real directory proven only by the generated banner. Weak proof
|
||||
# covers the SKILL.md, never the directory (#2119): remove our file,
|
||||
# marker and asset links; the user's own files stay, and the directory
|
||||
# goes only when that leaves it empty.
|
||||
_cleanup_weak_dir "$skills_dir/$n" "$gstack_dir"
|
||||
fi
|
||||
fi
|
||||
echo " pruned retired skill: $n"
|
||||
done
|
||||
@@ -199,9 +382,18 @@ log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
|
||||
|
||||
# Aside (aside.com, macOS 15+) is the primary browser; the compiled browse
|
||||
# binary is the fallback. Best-effort hint only — no probe of a running app.
|
||||
# Reads _PW_FAIL_REASON (the best-effort Chromium bootstrap in # 2 records why
|
||||
# the bundled browser is unusable) so the line never promises a fallback
|
||||
# browser that cannot launch. Pinned by test/setup-browser-hint.test.ts.
|
||||
_browser_hint() {
|
||||
if command -v aside >/dev/null 2>&1; then
|
||||
log " browser: Aside (primary) — gstack browser is the fallback"
|
||||
if [ -n "${_PW_FAIL_REASON:-}" ]; then
|
||||
log " browser: Aside (primary) — gstack browser fallback unavailable (Chromium bootstrap: ${_PW_FAIL_REASON})"
|
||||
else
|
||||
log " browser: Aside (primary) — gstack browser is the fallback"
|
||||
fi
|
||||
elif [ -n "${_PW_FAIL_REASON:-}" ]; then
|
||||
log " browser: none available — Chromium bootstrap: ${_PW_FAIL_REASON}; install Aside (aside.com, macOS 15+) or fix the bootstrap and re-run ./setup"
|
||||
else
|
||||
log " browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)"
|
||||
fi
|
||||
@@ -452,6 +644,13 @@ _kill_tree() {
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
_kill_tree "$child"
|
||||
done
|
||||
elif [ -d /proc ]; then
|
||||
# debian-slim and git-bash ship no pgrep: walk /proc for children. The
|
||||
# comm field "(name)" may contain spaces and parens, so strip through the
|
||||
# LAST closing paren (proc(5)) before reading the ppid (second field after it).
|
||||
for child in $(awk -v p="$pid" '{ s=$0; sub(/^.*\) /, "", s); split(s, f, " "); if (f[2]==p) print $1 }' /proc/[0-9]*/stat 2>/dev/null); do
|
||||
_kill_tree "$child"
|
||||
done
|
||||
fi
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
}
|
||||
@@ -813,7 +1012,42 @@ if [ -f /etc/os-release ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ensure_playwright_browser; then
|
||||
# Chromium is BEST-EFFORT (#1900, #1901, #1902, #913, #2233). Every later step
|
||||
# — skill registration (# 4), Codex/Kiro installs, migrations, hooks — is
|
||||
# independent of the browser, so a failed or wedged download must never abort
|
||||
# setup under `set -e`. Each failure records a reason code in _PW_FAIL_REASON;
|
||||
# the skills that need Chromium (/qa, /qa-only, /design-review, /browse,
|
||||
# make-pdf, /pair-agent) are named in the final summary instead of the user
|
||||
# discovering a half-installed gstack. Lock contention is a reason too: another
|
||||
# setup is installing Chromium right now, so this run registers skills and
|
||||
# re-probes next time. The download is bounded (default 600s, env
|
||||
# GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT) because Playwright's own retries cover a
|
||||
# flaky socket but not a wedged bunx; a wedged installer is killed with its
|
||||
# child tree (_kill_tree: pgrep-walked, /proc-walked where pgrep is missing).
|
||||
# Reason codes: skipped, chromium-install,
|
||||
# chromium-install-timeout, chromium-install-locked, windows-no-node,
|
||||
# windows-node-modules, post-install-launch.
|
||||
# test/setup-playwright-best-effort.test.ts pins this block.
|
||||
_PW_FAIL_REASON=""
|
||||
_pw_fail() {
|
||||
local code="$1"; shift
|
||||
_PW_FAIL_REASON="${_PW_FAIL_REASON:+$_PW_FAIL_REASON,}$code"
|
||||
echo " Chromium bootstrap: $code — $*" >&2
|
||||
}
|
||||
_PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
|
||||
# Normalize to a plain positive integer or fall back to the default: empty and
|
||||
# non-numeric are garbage; "0"/"000" would kill the install on the first poll;
|
||||
# "0600" is 600; anything past nine digits is not a deadline (a value bash
|
||||
# cannot compare would leave the install unbounded — the exact failure the
|
||||
# bound exists to prevent).
|
||||
case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac
|
||||
[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600
|
||||
_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))
|
||||
[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600
|
||||
|
||||
if [ "${GSTACK_SKIP_PLAYWRIGHT:-0}" = "1" ]; then
|
||||
_pw_fail skipped "GSTACK_SKIP_PLAYWRIGHT=1 — Chromium install skipped by request (#913)"
|
||||
elif ! ensure_playwright_browser; then
|
||||
echo "Installing Playwright Chromium..."
|
||||
# XProtect self-heal (#2554): the probe failure may be the OS killing the
|
||||
# cached Chromium, not a missing install. Clear quarantine on the Playwright
|
||||
@@ -823,11 +1057,47 @@ if ! ensure_playwright_browser; then
|
||||
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
|
||||
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
|
||||
# reclaim instead of telling the user to rmdir by hand.
|
||||
if [ -d "$_PW_LOCK" ] && [ -f "$_PW_LOCK/pid" ]; then
|
||||
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
||||
if [ -n "$_PW_HOLDER" ] && ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
||||
echo " reclaiming stale Chromium-install lock (holder pid $_PW_HOLDER is gone)" >&2
|
||||
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
||||
if [ -d "$_PW_LOCK" ]; then
|
||||
_PW_STALE=0; _PW_HOLDER=0
|
||||
_PW_LOCK_OLD=""
|
||||
[ -n "$(find "$_PW_LOCK" -maxdepth 0 -mmin +$(( _PW_INSTALL_TIMEOUT / 60 + 1 )) 2>/dev/null)" ] && _PW_LOCK_OLD=1
|
||||
if [ ! -f "$_PW_LOCK/pid" ]; then
|
||||
# No holder recorded (killed between mkdir and echo, or mid-write by a
|
||||
# live setup): nothing to probe, so only age can prove abandonment.
|
||||
[ -n "$_PW_LOCK_OLD" ] && _PW_STALE=1
|
||||
else
|
||||
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
||||
# A pid must be a positive integer: "", "-1" (kill -0 -1 signals every
|
||||
# process and "succeeds") or "0" (the process group) are stale, not live.
|
||||
case "$_PW_HOLDER" in ''|*[!0-9]*) _PW_HOLDER=0 ;; esac
|
||||
if [ "$_PW_HOLDER" -eq 0 ] || ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
||||
_PW_STALE=1
|
||||
elif [ -n "$_PW_LOCK_OLD" ]; then
|
||||
# The holder is alive but the lock is older than the install bound: the
|
||||
# holder is past its own deadline, or its pid was recycled to an
|
||||
# unrelated long-lived process. Either way nobody is installing.
|
||||
_PW_STALE=1
|
||||
fi
|
||||
fi
|
||||
if [ "$_PW_STALE" -eq 1 ]; then
|
||||
echo " reclaiming stale Chromium-install lock (holder pid ${_PW_HOLDER:-?} is gone or past the install bound)" >&2
|
||||
# Rename first: two setups judging the same lock stale race on rm -rf +
|
||||
# mkdir, and the loser would delete the winner's fresh lock. mv of a
|
||||
# directory is atomic, so exactly one of them reclaims — and if the dir
|
||||
# we moved already belongs to a NEW live holder (it re-created the lock
|
||||
# between our judgment and our mv), hand it straight back.
|
||||
if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then
|
||||
_PW_MOVED_PID=$(cat "$_PW_LOCK.stale.$$/pid" 2>/dev/null || true)
|
||||
case "$_PW_MOVED_PID" in ''|*[!0-9]*) _PW_MOVED_PID=0 ;; esac
|
||||
if { [ "$_PW_MOVED_PID" -gt 0 ] && [ "$_PW_MOVED_PID" != "$_PW_HOLDER" ] && kill -0 "$_PW_MOVED_PID" 2>/dev/null; } \
|
||||
|| { [ ! -f "$_PW_LOCK.stale.$$/pid" ] && [ -z "$_PW_LOCK_OLD" ]; }; then
|
||||
# A new live holder, or a fresh lock whose holder has not written its
|
||||
# pid yet (mkdir done, echo pending): not ours to reclaim.
|
||||
mv "$_PW_LOCK.stale.$$" "$_PW_LOCK" 2>/dev/null || true
|
||||
else
|
||||
rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if mkdir "$_PW_LOCK" 2>/dev/null; then
|
||||
@@ -843,48 +1113,58 @@ if ! ensure_playwright_browser; then
|
||||
else
|
||||
bunx playwright install chromium
|
||||
fi
|
||||
)
|
||||
) &
|
||||
_PW_PID=$!
|
||||
# Ctrl-C during the download: a backgrounded child ignores SIGINT, so
|
||||
# without this the installer would keep running as an orphan while the
|
||||
# EXIT trap frees the lock — the #2136 pile-up the lock exists to prevent.
|
||||
trap '_kill_tree "$_PW_PID" 2>/dev/null; rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun; exit 130' INT TERM
|
||||
_PW_RC=0
|
||||
_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT" || _PW_RC=$?
|
||||
trap - INT TERM
|
||||
if [ "$_PW_RC" -eq 124 ]; then
|
||||
_pw_fail chromium-install-timeout "bunx playwright install chromium exceeded ${_PW_INSTALL_TIMEOUT}s and was killed (raise with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>)"
|
||||
elif [ "$_PW_RC" -ne 0 ]; then
|
||||
_pw_fail chromium-install "bunx playwright install chromium exited $_PW_RC (offline, proxy, or blocked download?)"
|
||||
fi
|
||||
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
||||
# Restore the original handler (never `trap - EXIT`, which would clear
|
||||
# cleanup_copied_bun for the rest of the script).
|
||||
trap cleanup_copied_bun EXIT
|
||||
else
|
||||
echo " another gstack setup is already installing Chromium (lock: $_PW_LOCK)." >&2
|
||||
echo " Wait for it to finish, then re-run ./setup. If no other setup is running," >&2
|
||||
echo " remove the stale lock: rm -rf \"$_PW_LOCK\"" >&2
|
||||
exit 1
|
||||
_pw_fail chromium-install-locked "another gstack setup is installing Chromium (lock: $_PW_LOCK) — re-run ./setup after it finishes, or remove a stale lock: rm -rf \"$_PW_LOCK\""
|
||||
fi
|
||||
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
if [ -z "$_PW_FAIL_REASON" ] && [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
|
||||
# Ensure playwright is importable by Node from the gstack directory.
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
|
||||
echo " Install Node.js: https://nodejs.org/" >&2
|
||||
exit 1
|
||||
_pw_fail windows-no-node "Node.js is required on Windows to launch Chromium (Bun cannot: oven-sh/bun#4253) — install from https://nodejs.org/ and re-run ./setup"
|
||||
else
|
||||
echo "Windows detected — verifying Node.js can load Playwright..."
|
||||
if ! (
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
# Bun's node_modules already has playwright; verify Node can require it.
|
||||
# @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime;
|
||||
# verify the platform-specific native binary is installed so /pair-agent
|
||||
# tunnels don't fail later with a cryptic module-not-found error.
|
||||
# &&-chained: errexit is off inside an `if` condition, so a failed npm
|
||||
# install on the first line must not be masked by the second.
|
||||
{ node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright; } &&
|
||||
{ node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok; }
|
||||
); then
|
||||
_pw_fail windows-node-modules "npm could not install playwright / @ngrok/ngrok for Node.js"
|
||||
fi
|
||||
fi
|
||||
echo "Windows detected — verifying Node.js can load Playwright..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
# Bun's node_modules already has playwright; verify Node can require it
|
||||
node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright
|
||||
# @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime.
|
||||
# Verify the platform-specific native binary is installed so /pair-agent
|
||||
# tunnels don't fail later with a cryptic module-not-found error.
|
||||
node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ensure_playwright_browser; then
|
||||
if [ -z "$_PW_FAIL_REASON" ] && ! ensure_playwright_browser; then
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
|
||||
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
|
||||
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
|
||||
_pw_fail post-install-launch "Playwright Chromium could not be launched via Node.js (oven-sh/bun#4253) — ensure 'node -e \"require('playwright')\"' works, then re-run ./setup"
|
||||
else
|
||||
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
|
||||
_pw_fail post-install-launch "Playwright Chromium installed but could not be launched — on Ubuntu 24.04+ (AppArmor blocks unprivileged user namespaces) try GSTACK_CHROMIUM_NO_SANDBOX=1 (#2157)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
|
||||
@@ -896,7 +1176,9 @@ if ! ensure_emoji_font; then
|
||||
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
|
||||
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
|
||||
echo " Alpine: sudo apk add font-noto-emoji" >&2
|
||||
else
|
||||
elif [ -z "$_PW_FAIL_REASON" ]; then
|
||||
# Only when Chromium is actually usable — restarting a daemon that cannot
|
||||
# launch its browser just produces a second failure line.
|
||||
refresh_browse_daemon_for_fonts
|
||||
fi
|
||||
|
||||
@@ -922,6 +1204,12 @@ mkdir -p "$HOME/.gstack/projects"
|
||||
_link_skill_runtime_assets() {
|
||||
local src_dir="$1"
|
||||
local dst_dir="$2"
|
||||
# $3 = 0 when the destination directory is not provably ours (pre-existed
|
||||
# unclaimed, or only weakly proven where gstack never wrote real assets): its
|
||||
# real files are the user's, so a same-named real asset is kept and reported
|
||||
# instead of replaced (#2119). Symlinks are never content and are always
|
||||
# refreshed. Default 1 = the directory is ours.
|
||||
local replace_real="${3:-1}"
|
||||
local asset asset_name
|
||||
for asset in "$src_dir"/*; do
|
||||
[ -e "$asset" ] || continue # empty-glob guard
|
||||
@@ -929,8 +1217,12 @@ _link_skill_runtime_assets() {
|
||||
case "$asset_name" in
|
||||
SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
|
||||
esac
|
||||
# Refresh unconditionally: rm the old entry (symlink OR real copy — the
|
||||
# Windows install pattern) so re-runs after `git pull` pick up changes.
|
||||
if [ -e "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ] && [ "$replace_real" != "1" ]; then
|
||||
echo " kept ${dst_dir##*/}/$asset_name: a file you own already uses that name — left untouched" >&2
|
||||
continue
|
||||
fi
|
||||
# Refresh: rm the old entry (symlink OR real copy — the Windows install
|
||||
# pattern) so re-runs after `git pull` pick up changes.
|
||||
if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
|
||||
rm -rf "$dst_dir/$asset_name"
|
||||
fi
|
||||
@@ -954,6 +1246,27 @@ _link_skill_runtime_assets() {
|
||||
# gstack/ (which would auto-prefix them as gstack-*).
|
||||
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
|
||||
# Use --no-prefix to restore flat names.
|
||||
# Run gstack-relink and surface only what the user must see: foreign entries it
|
||||
# skipped (deduped against the ones this setup already reported, same wording)
|
||||
# and any pre-existing SKILL.md it moved to the backup root.
|
||||
_run_relink_quiet() {
|
||||
local out line name
|
||||
out="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1 || true)"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
' skipped '*)
|
||||
name="${line# skipped }"; name="${name%%:*}"
|
||||
case " ${_FOREIGN_SKIPPED_ENTRIES[*]:-} " in
|
||||
*" $name "*) ;;
|
||||
*) echo "$line" >&2; _FOREIGN_SKIPPED_ENTRIES+=("$name") ;;
|
||||
esac ;;
|
||||
'Moved '*|' cleaned '*) echo " ${line# }" >&2 ;;
|
||||
esac
|
||||
done <<EOF
|
||||
$out
|
||||
EOF
|
||||
}
|
||||
|
||||
link_claude_skill_dirs() {
|
||||
local gstack_dir="$1"
|
||||
local skills_dir="$2"
|
||||
@@ -976,6 +1289,32 @@ link_claude_skill_dirs() {
|
||||
link_name="$skill_name"
|
||||
fi
|
||||
target="$skills_dir/$link_name"
|
||||
# #2119: a destination that exists and is NOT ours is a user's skill that
|
||||
# shares our name. Never rm/mkdir/ln into it — skip and report.
|
||||
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _claude_entry_is_ours "$target" "$gstack_dir/$dir_name/SKILL.md" "$gstack_dir"; then
|
||||
echo " skipped $link_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
||||
_FOREIGN_SKIPPED_ENTRIES+=("$link_name")
|
||||
continue
|
||||
fi
|
||||
# Remember whether WE are creating this directory: only then may the
|
||||
# provenance marker below make it deletable whole. A directory we merely
|
||||
# link into (unclaimed, or a legacy install) never gets one — legacy
|
||||
# all-links dirs are removed by the only-links rule instead.
|
||||
# _assets_replace: may _link_skill_runtime_assets replace a REAL file or
|
||||
# dir already present under the target? Yes for a directory we create or
|
||||
# strongly own (marker, or SKILL.md symlink into gstack). On Windows also
|
||||
# for a weakly-proven real-file copy install (the legacy pre-marker shape,
|
||||
# whose asset copies are ours). Otherwise (unclaimed, or a weak copy on a
|
||||
# platform where gstack never wrote real assets) real files are the
|
||||
# user's and are kept.
|
||||
_pre_exists=0; _assets_replace=1
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
_pre_exists=1
|
||||
if _claude_entry_owned_strongly "$target" "$gstack_dir"; then _assets_replace=1
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ]; then _assets_replace=1
|
||||
else _assets_replace=0
|
||||
fi
|
||||
fi
|
||||
# Upgrade old directory symlinks to real directories
|
||||
if [ -L "$target" ]; then
|
||||
rm -f "$target"
|
||||
@@ -996,7 +1335,23 @@ link_claude_skill_dirs() {
|
||||
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
|
||||
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
|
||||
fi
|
||||
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
|
||||
# differs from what we are about to serve is moved aside, not overwritten.
|
||||
if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _claude_entry_owned_strongly "$target" "$gstack_dir" \
|
||||
&& ! cmp -s "$target/SKILL.md" "$_skill_md_src"; then
|
||||
if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
|
||||
echo " skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
|
||||
_FOREIGN_SKIPPED_ENTRIES+=("$link_name")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
|
||||
# Provenance marker (#2119) on every platform — path-independent proof
|
||||
# for Windows copies and for checkouts whose path carries no `gstack`
|
||||
# segment — but only for a directory we created or already owned: a
|
||||
# pre-existing unclaimed or weakly-proven directory must not become
|
||||
# deletable whole because we linked one file into it.
|
||||
if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then _write_owned_marker "$target" "$gstack_dir"; fi
|
||||
# Link every runtime asset the skill ships next to its SKILL.md (#2317,
|
||||
# #2454): sections/ for carved skills, review's checklist.md +
|
||||
# specialists/, qa's templates/ + references/, gstack-upgrade's
|
||||
@@ -1004,7 +1359,7 @@ link_claude_skill_dirs() {
|
||||
# landed and /review 404'd at "Read .claude/skills/review/checklist.md"
|
||||
# on every fresh Claude install. Routes through _link_or_copy so Windows
|
||||
# gets real copies refreshed on every ./setup.
|
||||
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target"
|
||||
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace"
|
||||
linked+=("$link_name")
|
||||
fi
|
||||
done
|
||||
@@ -1030,13 +1385,27 @@ _install_alias_skill_md() {
|
||||
local dst_dir="$2"
|
||||
local alias_name="$3"
|
||||
[ -f "$src_skill_md" ] || return 0
|
||||
# #2119: an existing alias-named entry that is not ours is a user's skill.
|
||||
# (Ours: a whole-dir symlink into gstack, or a copy carrying the generated
|
||||
# header — every alias copy does.)
|
||||
if { [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; } && ! _claude_entry_is_ours "$dst_dir" "$src_skill_md" "$SOURCE_GSTACK_DIR"; then
|
||||
echo " skipped $alias_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
||||
_FOREIGN_SKIPPED_ENTRIES+=("$alias_name")
|
||||
return 0
|
||||
fi
|
||||
# Old installs left the alias as a whole-dir symlink — replace it.
|
||||
_alias_pre=0
|
||||
if [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; then _alias_pre=1; fi
|
||||
if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
|
||||
mkdir -p "$dst_dir"
|
||||
# Remove any prior symlinked SKILL.md so the redirect below cannot write
|
||||
# through it into the generated source.
|
||||
rm -f "$dst_dir/SKILL.md"
|
||||
sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
|
||||
# A rewritten copy is a real file on every platform; the marker proves it
|
||||
# ours on the next run without leaning on the banner — but only for a
|
||||
# directory we created (or already marked), never one we merely wrote into.
|
||||
if [ "$_alias_pre" -eq 0 ] || [ -f "$dst_dir/.gstack-owned" ]; then _write_owned_marker "$dst_dir" "$SOURCE_GSTACK_DIR"; fi
|
||||
}
|
||||
|
||||
# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
|
||||
@@ -1092,17 +1461,20 @@ cleanup_old_claude_symlinks() {
|
||||
# render prefix (~/.gstack/render/claude/...), which is not `/gstack/`.
|
||||
case "$link_dest" in
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||
rm -rf "$old_target"
|
||||
_cleanup_linked_dir "$old_target" "$gstack_dir"
|
||||
removed+=("$skill_name")
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
||||
# available, so we can't readlink to verify provenance). Iterate known
|
||||
# gstack skill names from "$gstack_dir"/*, so a name match plus IS_WINDOWS
|
||||
# is safe to treat as gstack-managed during a mode flip. When the payload
|
||||
# is gone this branch is a no-op — a real file has no proven owner.
|
||||
# available, so we can't readlink to verify provenance). A bare name match
|
||||
# deleted a user's own same-name skill (#2119); ownership is now proven by
|
||||
# the .gstack-owned marker link_claude_skill_dirs writes, or — for copies
|
||||
# made before the marker existed — by the copy being byte-identical to the
|
||||
# gstack source SKILL.md or carrying gen-skill-docs' AUTO-GENERATED header
|
||||
# (every generated SKILL.md does; a hand-written skill does not). Anything
|
||||
# else is foreign and is left alone.
|
||||
if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then
|
||||
for skill_dir in "$gstack_dir"/*/; do
|
||||
if [ -f "$skill_dir/SKILL.md" ]; then
|
||||
@@ -1111,8 +1483,13 @@ cleanup_old_claude_symlinks() {
|
||||
case "$skill_name" in gstack-*) continue ;; esac
|
||||
old_target="$skills_dir/$skill_name"
|
||||
if [ -d "$old_target" ] && [ ! -L "$old_target" ] \
|
||||
&& [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ]; then
|
||||
rm -rf "$old_target"
|
||||
&& [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ] \
|
||||
&& { [ -f "$old_target/.gstack-owned" ] \
|
||||
|| cmp -s "$old_target/SKILL.md" "$skill_dir/SKILL.md" \
|
||||
|| _gstack_generated_header "$old_target/SKILL.md"; }; then
|
||||
# Only the marker proves we created the directory; weak proof covers
|
||||
# the SKILL.md alone (a user's files next to it survive).
|
||||
if [ -f "$old_target/.gstack-owned" ]; then rm -rf "$old_target"; else _cleanup_weak_dir "$old_target" "$gstack_dir" "$skill_dir/SKILL.md" "$skill_name"; fi
|
||||
removed+=("$skill_name")
|
||||
fi
|
||||
fi
|
||||
@@ -1138,11 +1515,13 @@ cleanup_prefixed_claude_symlinks() {
|
||||
# (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name)
|
||||
case "$skill_name" in gstack-*) continue ;; esac
|
||||
prefixed_target="$skills_dir/gstack-$skill_name"
|
||||
# Remove directory symlinks pointing into gstack/
|
||||
# Remove directory symlinks pointing into gstack/ — anchored path
|
||||
# segments, same as cleanup_old_claude_symlinks and gstack-uninstall: a
|
||||
# bare *gstack* substring would wipe a user skill under ~/tools/gstack-fork/.
|
||||
if [ -L "$prefixed_target" ]; then
|
||||
link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)"
|
||||
case "$link_dest" in
|
||||
gstack/*|*/gstack/*)
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||
rm -f "$prefixed_target"
|
||||
removed+=("gstack-$skill_name")
|
||||
;;
|
||||
@@ -1151,16 +1530,20 @@ cleanup_prefixed_claude_symlinks() {
|
||||
elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then
|
||||
link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)"
|
||||
case "$link_dest" in
|
||||
*gstack*)
|
||||
rm -rf "$prefixed_target"
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||
_cleanup_linked_dir "$prefixed_target" "$gstack_dir"
|
||||
removed+=("gstack-$skill_name")
|
||||
;;
|
||||
esac
|
||||
# Windows install pattern: real dir with real-file SKILL.md. Same
|
||||
# reasoning as cleanup_old_claude_symlinks — directory name match plus
|
||||
# IS_WINDOWS is safe during a mode flip.
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ]; then
|
||||
rm -rf "$prefixed_target"
|
||||
# Windows install pattern: real dir with real-file SKILL.md. Provenance
|
||||
# must be PROVEN (#2119), never assumed from the name: the marker
|
||||
# link_claude_skill_dirs writes, a byte-identical copy of the source, or
|
||||
# gen-skill-docs' generated header (legacy copies made before the marker).
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ] && [ ! -L "$prefixed_target/SKILL.md" ] \
|
||||
&& { [ -f "$prefixed_target/.gstack-owned" ] \
|
||||
|| cmp -s "$prefixed_target/SKILL.md" "$skill_dir/SKILL.md" \
|
||||
|| _gstack_generated_header "$prefixed_target/SKILL.md"; }; then
|
||||
if [ -f "$prefixed_target/.gstack-owned" ]; then rm -rf "$prefixed_target"; else _cleanup_weak_dir "$prefixed_target" "$gstack_dir" "$skill_dir/SKILL.md" "gstack-$skill_name"; fi
|
||||
removed+=("gstack-$skill_name")
|
||||
fi
|
||||
fi
|
||||
@@ -1718,7 +2101,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
|
||||
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
|
||||
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
|
||||
if [ -x "$GSTACK_RELINK" ]; then
|
||||
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
|
||||
_run_relink_quiet
|
||||
fi
|
||||
# Backwards-compat alias: /connect-chrome → /open-gstack-browser
|
||||
# Rewritten copy, not a symlink: a symlinked alias re-serves the canonical
|
||||
@@ -1793,7 +2176,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
|
||||
_CLAUDE_SKILLS_LINKED=1
|
||||
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
|
||||
if [ -x "$GSTACK_RELINK" ]; then
|
||||
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
|
||||
_run_relink_quiet
|
||||
fi
|
||||
# Rewritten copy, not a symlink: a symlinked alias re-serves the
|
||||
# canonical name: open-gstack-browser, so one of the two silently
|
||||
@@ -2031,7 +2414,7 @@ if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then
|
||||
log ""
|
||||
# Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup).
|
||||
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown >/dev/null 2>&1 || true
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown --no-sweep >/dev/null 2>&1 || true
|
||||
fi
|
||||
touch "$HOME/.gstack/.welcome-seen"
|
||||
fi
|
||||
@@ -2637,3 +3020,51 @@ if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
|
||||
log " installs the hook automatically in every repo you ship from."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
|
||||
# Printed LAST so it is the thing the user sees, after every skill registered.
|
||||
_PW_BROWSER_SKILLS="/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent, and any other skill that drives the browser"
|
||||
# The skills that drive Aside first and use the bundled browser only as fallback
|
||||
# (/pair-agent is not among them: it always runs on gstack's own browser).
|
||||
_PW_ASIDE_SKILLS="/qa, /qa-only, /design-review, /browse, /scrape, /benchmark, /canary, make-pdf, /diagram"
|
||||
if [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
|
||||
# An explicit opt-out is not a failure: say what is unavailable and stop.
|
||||
log ""
|
||||
log "Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)."
|
||||
log " Browser skills ($_PW_BROWSER_SKILLS) need it; re-run ./setup without the flag when you want them."
|
||||
elif [ -n "${_PW_FAIL_REASON:-}" ]; then
|
||||
log ""
|
||||
log "Browser unavailable: Chromium bootstrap did not complete ($_PW_FAIL_REASON)."
|
||||
if command -v aside >/dev/null 2>&1; then
|
||||
# Aside is the primary driver; the bundled browser is its fallback. Say so,
|
||||
# instead of telling an Aside user their browser skills are gone.
|
||||
log " Aside is installed, so $_PW_ASIDE_SKILLS keep running there; only their bundled fallback is missing."
|
||||
log " /pair-agent needs the bundled browser itself."
|
||||
else
|
||||
log " Skills that need it: $_PW_BROWSER_SKILLS."
|
||||
fi
|
||||
log " Everything else is installed and works. Fix the cause and re-run ./setup."
|
||||
case "$_PW_FAIL_REASON" in
|
||||
*chromium-install-timeout*) log " Slow link? Raise the bound: GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800 ./setup" ;;
|
||||
esac
|
||||
case "$_PW_FAIL_REASON" in
|
||||
*post-install-launch*) log " Ubuntu 24.04+ (AppArmor user namespaces): GSTACK_CHROMIUM_NO_SANDBOX=1 ./setup (#2157)" ;;
|
||||
esac
|
||||
# Reason code only — never a path, hostname, or the installer's output. The
|
||||
# event is a one-shot with no session of its own, so --no-sweep keeps it
|
||||
# from finalizing other live sessions' in-flight .pending markers as
|
||||
# outcome:unknown. Telemetry-gated inside gstack-telemetry-log.
|
||||
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_playwright --outcome "$_PW_FAIL_REASON" --no-sweep >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]} -gt 0 ]; then
|
||||
log ""
|
||||
log "Not registered (a skill you own already uses the name; left untouched): ${_FOREIGN_SKIPPED_ENTRIES[*]}"
|
||||
log " Rename or move yours, or switch modes (./setup --prefix / --no-prefix) so the names no longer collide."
|
||||
fi
|
||||
if [ ${#_BACKED_UP_SKILL_MDS[@]} -gt 0 ]; then
|
||||
log ""
|
||||
log "Moved ${#_BACKED_UP_SKILL_MDS[@]} customized SKILL.md file(s) to $_SKILL_BACKUP_ROOT before installing gstack's: ${_BACKED_UP_SKILL_MDS[*]}"
|
||||
log " Those were gstack-generated files you had edited; restore anything you meant to keep under a different skill name."
|
||||
fi
|
||||
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"tag": "v1.80.0.0",
|
||||
"tag": "v1.81.0.0",
|
||||
"capturedAt": "2026-09-05T20:36:18.449Z",
|
||||
"capturedFromCommit": "fe622529",
|
||||
"capturedFromBranch": "consolidate-browser-skills-into-aside",
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Run an assembled bash script from a TEMP FILE — never via `bash -c <argv>`.
|
||||
*
|
||||
* Why: the setup harnesses slice functions out of `setup` and join them into
|
||||
* one script. On Windows, bash is an MSYS2 program; when its parent is a
|
||||
* non-MSYS process (bun), msys-2.0.dll's build_argv() runs every argument
|
||||
* containing any of `?*["'(){}` through globify()/glob(). glob() copies the
|
||||
* pattern into a fixed `Char patbuf[8192]` and silently stops after
|
||||
* 8192 - MB_CUR_MAX (8186 characters under C.UTF-8); GLOB_NOCHECK then hands
|
||||
* the truncated text to bash as the argument. Observed on windows-free-tests
|
||||
* when the alias harness grew from 6.7 KB to 15.7 KB: the `-c` script was cut
|
||||
* inside a single-quoted token on line 178 ("unexpected EOF while looking for
|
||||
* matching `'"). A short, glob-character-free file path never enters
|
||||
* globify, and bash reads the file's bytes directly, so quoting and encoding
|
||||
* are never re-parsed by any argument layer. The same ~8186-char ceiling
|
||||
* applies to any MSYS tool (sh, sed, awk, grep) spawned from bun on Windows
|
||||
* with a long single argument.
|
||||
*
|
||||
* `timeout` is always set (test/spawnsync-timeout-tripwire.test.ts).
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface BashScriptResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export interface BashScriptOptions {
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function runBashScript(script: string, opts: BashScriptOptions = {}): BashScriptResult {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bash-script-'));
|
||||
const file = path.join(dir, 'script.sh');
|
||||
// LF only: a stray CR would reach bash as part of a token.
|
||||
fs.writeFileSync(file, script.replace(/\r\n/g, '\n'));
|
||||
try {
|
||||
const r = spawnSync('bash', [file], {
|
||||
encoding: 'utf-8',
|
||||
timeout: opts.timeout ?? 60_000,
|
||||
...(opts.env ? { env: opts.env } : {}),
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
});
|
||||
// A spawn failure (bash missing) or a timeout kill has no bash stderr of
|
||||
// its own; surface the cause instead of a bare status -1.
|
||||
const stderr = (r.stderr ?? '') + (r.error ? `\n[spawn] ${r.error.message}` : '');
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr, signal: r.signal ?? null };
|
||||
} finally {
|
||||
// Best-effort: an AV scanner or indexer still holding script.sh on
|
||||
// Windows must never turn a good result into an unlink error.
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
maxSkeletonBytes: 74_500, // + Aside browser contract for Step 7 canary ({{ASIDE_SETUP}}); measured 73_523
|
||||
maxSizeRatio: 1.10, // + v1.80 Aside contract + gstack-browser fallback block; measured 1.077
|
||||
maxSizeRatio: 1.10, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.077
|
||||
minUnionBytes: 91_000, // Phase 4 wave 1; estimated union ~94.9KB
|
||||
mustContain: ['readiness', 'merge', 'canary', 'revert', 'staging'],
|
||||
},
|
||||
@@ -607,7 +607,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
},
|
||||
behavioral: 'prompt',
|
||||
maxSkeletonBytes: 63_500, // + v2.0 {{ASIDE_SETUP}}/{{BROWSE_FALLBACK}} (replaces the browse setup block); measured 61_253
|
||||
maxSizeRatio: 1.08, // + v1.80 Aside contract + gstack-browser fallback block; measured 1.063
|
||||
maxSizeRatio: 1.08, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.063
|
||||
minUnionBytes: 69_500, // measured union 70,385
|
||||
// 'aside repl' pins the Aside contract; '$B goto' pins the fallback block in the always-loaded skeleton.
|
||||
mustContain: ['bug', 'aside repl', '$B goto', 'fix', 'Health Score Rubric', 'regression'],
|
||||
|
||||
+291
-15
@@ -76,12 +76,45 @@ function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) {
|
||||
}
|
||||
}
|
||||
|
||||
// The freeze WRITER resolves its state root through bin/gstack-paths, which
|
||||
// trusts CLAUDE_PLUGIN_DATA only when CLAUDE_PLUGIN_ROOT names gstack; the
|
||||
// reader mirrors that exact chain (#1459 / #1509). A test standing in for a
|
||||
// plugin install must supply both, and must neutralize a GSTACK_HOME inherited
|
||||
// from the shard's process.env (an empty value reads as unset in ${VAR:-}).
|
||||
function freezeEnv(stateDir: string, extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: stateDir, CLAUDE_PLUGIN_ROOT: '/plugins/gstack', ...extra };
|
||||
}
|
||||
|
||||
const HOOK_EXTRACT = path.join(ROOT, 'careful', 'bin', 'hook-extract.sh');
|
||||
const GSTACK_PATHS = path.join(ROOT, 'bin', 'gstack-paths');
|
||||
|
||||
/** What the hook helper resolves as the state root under a given env. */
|
||||
function hookStateRoot(env: Record<string, string>): string {
|
||||
const r = spawnSync('bash', ['-c', `. "${HOOK_EXTRACT}" && gstack_hook_state_root`], {
|
||||
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
|
||||
});
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
/** What bin/gstack-paths resolves as GSTACK_STATE_ROOT under the same env. */
|
||||
function pathsStateRoot(env: Record<string, string>): string {
|
||||
const r = spawnSync('bash', ['-c', `eval "$("${GSTACK_PATHS}")" && printf '%s' "$GSTACK_STATE_ROOT"`], {
|
||||
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
|
||||
});
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Frontmatter hook wiring (#2469 / #1871)
|
||||
// ============================================================
|
||||
// Frontmatter hooks run before any runtime variable exists, so a
|
||||
// ${CLAUDE_SKILL_DIR}-relative command silently never resolves and the guard
|
||||
// never fires. Every command: line must anchor on $HOME like careful/freeze.
|
||||
function withEmptyDir(fn: (dir: string) => void) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-empty-'));
|
||||
try { fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
describe('frontmatter hook command paths', () => {
|
||||
test.each(['investigate/SKILL.md', 'careful/SKILL.md', 'freeze/SKILL.md', 'guard/SKILL.md'])(
|
||||
'%s hook commands are $HOME-anchored, never CLAUDE_SKILL_DIR',
|
||||
@@ -666,6 +699,52 @@ describe('check-careful.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('an older hook-extract.sh without gstack_hook_state_root still loads rules from $HOME/.gstack and emits a decision (no set -e death)', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-oldhelper-'));
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(CAREFUL_SCRIPT, path.join(carefulBin, 'check-careful.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_state_root() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
const fakeHome = path.join(base, 'home');
|
||||
fs.mkdirSync(path.join(fakeHome, '.gstack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fakeHome, '.gstack', 'careful-patterns.txt'), 'terraform\\s+destroy\n');
|
||||
try {
|
||||
const { exitCode, output } = runHook(path.join(carefulBin, 'check-careful.sh'), carefulInput('terraform destroy'), { HOME: fakeHome, GSTACK_HOME: '' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin install: patterns under CLAUDE_PLUGIN_DATA load when CLAUDE_PLUGIN_ROOT names gstack (same root the writer uses)', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
|
||||
{ HOME: fakeHome, GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('GSTACK_HOME outranks CLAUDE_PLUGIN_DATA for careful patterns, exactly as for the freeze file', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
|
||||
withEmptyDir((pluginData) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
|
||||
{ GSTACK_HOME: gstackHome, CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('safe commands still allow with a pattern file present', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('ls -la'), { GSTACK_HOME: gstackHome });
|
||||
@@ -687,7 +766,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -699,7 +778,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src/components/Button.tsx'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -713,7 +792,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/other-project/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -727,7 +806,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/etc/hosts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -743,7 +822,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src-old/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -759,7 +838,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/anywhere/at/all.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -775,7 +854,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
{ tool_input: {} },
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -787,7 +866,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHookRaw(
|
||||
FREEZE_SCRIPT,
|
||||
'not json at all {{{{',
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -803,7 +882,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output, raw } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/tmp/evil"quoted/x.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
@@ -816,7 +895,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output, raw } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/tmp/evil\npath.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
@@ -834,11 +913,11 @@ describe('check-freeze.sh', () => {
|
||||
fs.mkdirSync(boundary, { recursive: true });
|
||||
try {
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), freezeEnv(stateDir));
|
||||
expect(inside.exitCode).toBe(0);
|
||||
expect(inside.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
|
||||
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), freezeEnv(stateDir));
|
||||
expect(outside.exitCode).toBe(0);
|
||||
expect(outside.output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
@@ -859,7 +938,7 @@ describe('check-freeze.sh', () => {
|
||||
fs.copyFileSync(FREEZE_SCRIPT, script);
|
||||
try {
|
||||
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
|
||||
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('fail closed');
|
||||
@@ -884,13 +963,13 @@ describe('check-freeze.sh', () => {
|
||||
fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(boundary, 'link.txt'));
|
||||
try {
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), freezeEnv(stateDir));
|
||||
expect(viaLink.exitCode).toBe(0);
|
||||
expect(viaLink.output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
|
||||
// A real in-boundary file is unaffected.
|
||||
fs.writeFileSync(path.join(boundary, 'real.txt'), 'y');
|
||||
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), freezeEnv(stateDir));
|
||||
expect(real.exitCode).toBe(0);
|
||||
expect(real.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
@@ -900,3 +979,200 @@ describe('check-freeze.sh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-freeze.sh state-root resolution (#1459 / #1509)
|
||||
// ============================================================
|
||||
// /freeze writes freeze-dir.txt under the root gstack-paths resolves
|
||||
// (GSTACK_HOME first). The reader used to read ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}
|
||||
// — so with GSTACK_HOME set it found no file and ALLOWED everything. A deny-tier
|
||||
// boundary that fails open is not a boundary; writer and reader now share one
|
||||
// chain (gstack_hook_state_root in careful/bin/hook-extract.sh).
|
||||
describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => {
|
||||
const BOUNDARY = '/Users/dev/project/src/';
|
||||
const OUTSIDE = '/Users/dev/other-project/index.ts';
|
||||
|
||||
|
||||
test('REGRESSION: freeze file under GSTACK_HOME (HOME has none) denies an outside edit', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('GSTACK_HOME wins over CLAUDE_PLUGIN_DATA (matches gstack-paths precedence)', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((gstackHome) => {
|
||||
// The freeze file lives under CLAUDE_PLUGIN_DATA, but GSTACK_HOME is set and
|
||||
// has none — the writer would have written there, so the reader must look there.
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { GSTACK_HOME: gstackHome }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('CLAUDE_PLUGIN_DATA is ignored when CLAUDE_PLUGIN_ROOT is another plugin', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { CLAUDE_PLUGIN_ROOT: '/plugins/codex', HOME: fakeHome }));
|
||||
// Falls through to $HOME/.gstack, which has no freeze file → allow.
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('CLAUDE_PLUGIN_DATA is honoured when CLAUDE_PLUGIN_ROOT names gstack', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), freezeEnv(pluginData, { HOME: fakeHome }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('gstack_hook_state_root is byte-identical to gstack-paths GSTACK_STATE_ROOT', () => {
|
||||
const combos: Record<string, string>[] = [
|
||||
{ HOME: '/home/u', GSTACK_HOME: '/state/x', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/codex' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
|
||||
{ HOME: '', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
|
||||
];
|
||||
for (const env of combos) {
|
||||
expect(hookStateRoot(env)).toBe(pathsStateRoot(env));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// gstack_hook_log_fire analytics sink follows the same state root (#1459)
|
||||
// ============================================================
|
||||
// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics —
|
||||
// the SAME two-step chain every other analytics writer and reader uses
|
||||
// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately
|
||||
// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays
|
||||
// one file. Logging is best-effort: an unwritable sink never changes the decision.
|
||||
describe('gstack_hook_log_fire writes under the resolved state root', () => {
|
||||
const BOUNDARY = '/Users/dev/project/src/';
|
||||
const OUTSIDE = '/Users/dev/other-project/index.ts';
|
||||
|
||||
function lastRecord(file: string): any {
|
||||
const lines = fs.readFileSync(file, 'utf-8').trim().split('\n');
|
||||
return JSON.parse(lines[lines.length - 1]);
|
||||
}
|
||||
|
||||
test('REGRESSION: a freeze deny under GSTACK_HOME appends hook_fire to $GSTACK_HOME/analytics, not $HOME/.gstack', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const rec = lastRecord(path.join(gstackHome, 'analytics', 'skill-usage.jsonl'));
|
||||
expect(rec.event).toBe('hook_fire');
|
||||
expect(rec.skill).toBe('freeze');
|
||||
expect(rec.pattern).toBe('boundary_deny');
|
||||
expect(typeof rec.ts).toBe('string');
|
||||
expect(fs.existsSync(path.join(fakeHome, '.gstack'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl'));
|
||||
expect(rec.event).toBe('hook_fire');
|
||||
expect(rec.skill).toBe('freeze');
|
||||
expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a GSTACK_HOME ending in a newline round-trips exactly (writer %q and reader sentinel agree)', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-nl-'));
|
||||
const nlDir = path.join(base, 'root\n');
|
||||
fs.mkdirSync(nlDir);
|
||||
fs.writeFileSync(path.join(nlDir, 'freeze-dir.txt'), BOUNDARY);
|
||||
try {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: nlDir, HOME: base, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unexpected set -e death inside the hook (a tool on PATH failing) DENIES via the EXIT backstop instead of exiting with no JSON', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-backstop-'));
|
||||
const fakeBin = path.join(base, 'bin');
|
||||
fs.mkdirSync(fakeBin);
|
||||
fs.writeFileSync(path.join(fakeBin, 'head'), '#!/bin/sh\nexit 1\n');
|
||||
fs.chmodSync(path.join(fakeBin, 'head'), 0o755);
|
||||
try {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput('/Users/dev/project/src/x.ts'),
|
||||
freezeEnv(stateDir, { PATH: `${fakeBin}:${process.env.PATH ?? ''}` }));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('failed unexpectedly');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a hook helper from an older install that lacks gstack_hook_state_root DENIES (fail closed), never exit 127', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-oldhelper-'));
|
||||
const freezeBin = path.join(base, 'freeze', 'bin');
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(freezeBin, { recursive: true });
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(FREEZE_SCRIPT, path.join(freezeBin, 'check-freeze.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_state_root() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
try {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(path.join(freezeBin, 'check-freeze.sh'), freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
// 'out of date' is the helper-without-function branch; the plain
|
||||
// helpers-unavailable deny also says 'fail closed', so pin the specific one.
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('out of date');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwritable analytics sink never changes the decision: deny is still emitted as valid JSON', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
// `analytics` is a regular FILE, so mkdir -p and the >> append both fail.
|
||||
fs.writeFileSync(path.join(gstackHome, 'analytics'), 'not a directory');
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output, raw } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(fs.readFileSync(path.join(gstackHome, 'analytics'), 'utf-8')).toBe('not a directory');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -672,3 +672,563 @@ describe('gstack-patch-names (#620/#578)', () => {
|
||||
expect(content).toBe('# qa\nSome content.');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Ownership gate (#2119): relink runs on every ./setup and must never delete
|
||||
// or link over a skill it does not own. Ownership = symlink into INSTALL_DIR
|
||||
// or RENDER_DIR, a real dir whose SKILL.md is such a symlink, or the
|
||||
// .gstack-owned marker setup writes for Windows copy installs.
|
||||
// ============================================================
|
||||
describe('gstack-relink ownership gate (#2119)', () => {
|
||||
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
|
||||
|
||||
function relink(env: Record<string, string> = {}): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir,
|
||||
GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
...env,
|
||||
});
|
||||
}
|
||||
function setPrefix(v: 'true' | 'false') {
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('flat mode: a foreign real dir with a real SKILL.md is never linked over (Linux ln -snf would replace the file)', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
// The foreign skill exists before gstack ever runs (gstack-config `set`
|
||||
// auto-relinks, so fixtures go in first).
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
const md = path.join(skillsDir, 'qa', 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
expect(out).toContain('Skipped 1 foreign entry');
|
||||
// The other skill still links normally.
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test('prefix flip: a foreign flat entry sharing a skill name survives the cleanup pass', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
|
||||
setPrefix('true');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a foreign directory symlink sharing a skill name is left in place', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
|
||||
fs.mkdirSync(elsewhere, { recursive: true });
|
||||
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
|
||||
fs.symlinkSync(elsewhere, path.join(skillsDir, 'qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(elsewhere);
|
||||
expect(fs.readFileSync(path.join(elsewhere, 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry whose SKILL.md links into RENDER_DIR is ours and is cleaned on a mode flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
const renderDir = path.join(tmpDir, 'render', 'claude');
|
||||
fs.mkdirSync(path.join(renderDir, 'qa'), { recursive: true });
|
||||
fs.writeFileSync(path.join(renderDir, 'qa', 'SKILL.md'), '---\nname: gstack-qa\ndescription: rendered\n---\n');
|
||||
// Stale prefixed entry from a prior prefix-mode run, pointing at the render.
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(renderDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink({ GSTACK_USER_RENDER_DIR: renderDir });
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(renderDir, 'qa', 'SKILL.md'));
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a real-file copy carrying the .gstack-owned marker (Windows install shape) is ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), '');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a legacy RELATIVE symlink into the install (gstack/qa/SKILL.md) is ours and is cleaned', () => {
|
||||
setupMockInstall(['qa']);
|
||||
// Older setups wrote relative links; the skills dir sits beside the install
|
||||
// dir named `gstack`, so `gstack/qa/SKILL.md` resolves into INSTALL_DIR.
|
||||
const installAlias = path.join(skillsDir, 'gstack');
|
||||
fs.symlinkSync(installDir, installAlias);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry linked against the REAL path of a symlinked install dir is ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const realInstall = fs.realpathSync(installDir);
|
||||
const linkInstall = path.join(tmpDir, 'install-link');
|
||||
fs.symlinkSync(realInstall, linkInstall);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(realInstall, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
// relink detects the install through the symlinked spelling.
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
||||
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
const out = run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a real-file copy WITHOUT the marker is foreign and survives', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), FOREIGN);
|
||||
const out = relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
// Before the gate, a stray regular FILE named like a skill made `mkdir -p`
|
||||
// fail under set -e and relink died mid-loop (exit 1, later skills never
|
||||
// linked). A non-dir, non-symlink entry is simply foreign now.
|
||||
test('a stray regular file with a skill name is foreign: relink completes, file untouched, other skills link', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa'), 'stray notes\n');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isFile()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa'), 'utf-8')).toBe('stray notes\n');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(out).toContain('Relinked 1 skills as flat names');
|
||||
expect(out).toContain('Skipped 1 foreign entry');
|
||||
});
|
||||
|
||||
test('a real dir whose SKILL.md symlink points OUTSIDE install/render is foreign in both the link pass and the flip cleanup', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
|
||||
fs.mkdirSync(elsewhere, { recursive: true });
|
||||
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(elsewhere, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
// Link pass (flat mode): the destination is not ours → never re-pointed.
|
||||
setPrefix('false');
|
||||
let out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
|
||||
expect(out).toContain('skipped');
|
||||
// Cleanup pass (prefix flip): the stale flat name is not ours → kept.
|
||||
setPrefix('true');
|
||||
out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a sibling directory that merely shares the install dir as a prefix (…/gstack-install-fork) is not ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const fork = installDir + '-fork';
|
||||
fs.mkdirSync(path.join(fork, 'qa'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fork, 'qa', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(fork, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'qa', 'SKILL.md'));
|
||||
expect(fs.readFileSync(path.join(fork, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('several foreign entries: pluralized summary lists every name and the relinked count excludes them', () => {
|
||||
setupMockInstall(['qa', 'ship', 'review']);
|
||||
for (const name of ['qa', 'ship']) {
|
||||
fs.mkdirSync(path.join(skillsDir, name));
|
||||
fs.writeFileSync(path.join(skillsDir, name, 'SKILL.md'), FOREIGN);
|
||||
}
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('Relinked 1 skills as flat names');
|
||||
expect(out).toContain('Skipped 2 foreign entries');
|
||||
// Bare names, same wording as setup's own line, so setup can dedupe when it forwards relink's output.
|
||||
expect(out).toContain('skipped qa: existing entry is not gstack-managed');
|
||||
expect(out).toContain('skipped ship: existing entry is not gstack-managed');
|
||||
expect(out).toContain('left untouched): qa ship');
|
||||
expect(out).not.toContain('foreign entry ');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'review', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test('an opposite-mode WHOLE-DIR symlink into the install (oldest install shape) is ours and is removed on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'gstack-qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'gstack-qa'), { throwIfNoEntry: false })).toBeUndefined();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink ownership gate parity with setup (#2119 review fixes)', () => {
|
||||
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
|
||||
function relink(env: Record<string, string> = {}): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
...env,
|
||||
});
|
||||
}
|
||||
function setPrefix(v: 'true' | 'false') {
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('a pre-marker legacy COPY carrying the generated header is ours (same rule as setup) and is cleaned on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# qa\n');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a ONE-line AUTO-GENERATED substring is not provenance (another generator could emit it): entry survives', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-tool -->\n# theirs\n');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('an absolute link that walks through a gstack segment with `..` is canonicalized first, not fast-pathed as ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const decoy = path.join(tmpDir, 'x', 'gstack');
|
||||
const foreign = path.join(tmpDir, 'x', 'foreign');
|
||||
fs.mkdirSync(decoy, { recursive: true });
|
||||
fs.mkdirSync(foreign, { recursive: true });
|
||||
fs.writeFileSync(path.join(foreign, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(decoy, '..', 'foreign', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a byte-identical copy of our source SKILL.md is ours even without marker or header', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry linked into a SIBLING gstack checkout (path segment `gstack`) is ours, like setup and uninstall treat it', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
const sibling = path.join(tmpDir, 'worktrees', 'gstack', 'qa');
|
||||
fs.mkdirSync(sibling, { recursive: true });
|
||||
fs.writeFileSync(path.join(sibling, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(sibling, 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a fork under a `gstack-fork` directory is NOT ours (segment must be exactly gstack)', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const fork = path.join(tmpDir, 'tools', 'gstack-fork', 'qa');
|
||||
fs.mkdirSync(fork, { recursive: true });
|
||||
fs.writeFileSync(path.join(fork, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(fork, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'SKILL.md'));
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a DANGLING link from a moved checkout is healed, not reported foreign', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(tmpDir, 'old-checkout', 'gstack', 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).not.toContain('skipped');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink root alias (_gstack-command) ownership gate', () => {
|
||||
const ROOT_SKILL = '---\nname: gstack\ndescription: root\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# gstack root\n';
|
||||
function relink(): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('a user-owned _gstack-command skill is reported and left byte-identical', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
|
||||
const mine = '---\nname: _gstack-command\ndescription: my own command runner\n---\n# mine\n';
|
||||
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
|
||||
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), mine);
|
||||
const out = relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toBe(mine);
|
||||
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
|
||||
expect(out).toContain('skipped');
|
||||
expect(out).toContain('_gstack-command');
|
||||
});
|
||||
|
||||
test('our own rewritten alias copy is refreshed and stamped with the ownership marker', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
|
||||
const first = relink();
|
||||
expect(first).not.toContain('skipped');
|
||||
const alias = path.join(skillsDir, '_gstack-command');
|
||||
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
|
||||
expect(fs.existsSync(path.join(alias, '.gstack-owned'))).toBe(true);
|
||||
// Stale copy (older render) with the marker: refreshed, not reported.
|
||||
fs.writeFileSync(path.join(alias, 'SKILL.md'), '---\nname: _gstack-command\n---\n# stale\n');
|
||||
const second = relink();
|
||||
expect(second).not.toContain('skipped');
|
||||
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('# gstack root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink weak proof is file-scoped; differing files are moved aside (#2119 review)', () => {
|
||||
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
|
||||
const env = () => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
const backups = () => {
|
||||
const root = path.join(tmpDir, 'home', 'backups', 'skills');
|
||||
if (!fs.existsSync(root)) return [] as string[];
|
||||
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
|
||||
};
|
||||
|
||||
test('flip cleanup on a banner-only copy removes SKILL.md and keeps the user\'s other files and the directory', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), `---\nname: gstack-qa\n---\n${BANNER}\n# started from gstack\n`);
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a marker-proven directory (we created it) is still removed whole on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '# stale copy\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'sections', 'a.md'), 'a\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), installDir + '\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
});
|
||||
|
||||
test('linking over a CUSTOMIZED banner copy moves it to the backup root first; the link then lands', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: qa\n---\n${BANNER}\n# my qa, started from gstack\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
const saved = backups();
|
||||
expect(saved.length).toBe(1);
|
||||
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('linking over a byte-identical copy makes no backup', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(backups()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 review)', () => {
|
||||
const env = () => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
|
||||
test('an entry linked into a checkout whose path has no `gstack` segment (git worktree add ../gs-feature) is ours when that tree carries setup + VERSION + bin/', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const other = path.join(tmpDir, 'gs-feature');
|
||||
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
|
||||
fs.mkdirSync(path.join(other, 'bin'));
|
||||
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).not.toContain('skipped');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
|
||||
// ...but a hand-written skill repo with VERSION + setup + bin/ (no gstack-relink) is not a gstack tree.
|
||||
const plain = path.join(tmpDir, 'plain-tools');
|
||||
fs.mkdirSync(path.join(plain, 'ship'), { recursive: true });
|
||||
fs.mkdirSync(path.join(plain, 'bin'));
|
||||
fs.writeFileSync(path.join(plain, 'VERSION'), '0.1\n');
|
||||
fs.writeFileSync(path.join(plain, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(plain, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.mkdirSync(path.join(installDir, 'ship'));
|
||||
fs.writeFileSync(path.join(installDir, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'ship'));
|
||||
fs.symlinkSync(path.join(plain, 'ship', 'SKILL.md'), path.join(skillsDir, 'ship', 'SKILL.md'));
|
||||
const out2 = relink();
|
||||
expect(out2).toContain('skipped ship');
|
||||
});
|
||||
|
||||
test('a directory relink creates gets the marker on Linux; a pre-existing unclaimed directory does not', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'ship'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'mine\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'ship', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
|
||||
test('flip on a legacy linked dir (no marker): all-links dir is removed whole; a dir with a user file keeps the file and drops our links', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
|
||||
for (const name of ['gstack-qa', 'gstack-ship']) {
|
||||
fs.mkdirSync(path.join(skillsDir, name));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, name, 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, name, 'sections'));
|
||||
}
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'keep\n');
|
||||
// gstack-config `set` auto-relinks, so the flip cleanup runs there; capture both outputs.
|
||||
const out = setPrefix('false') + relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'sections'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
|
||||
expect(out).toContain('cleaned gstack-ship/SKILL.md');
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink cycle-3 hardening: foreign dir links, failed backups, flip backups, mixed dirs (#2119 review)', () => {
|
||||
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
|
||||
const env = (extra: Record<string, string> = {}) => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'), ...extra,
|
||||
});
|
||||
const relink = (extra: Record<string, string> = {}) => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env(extra));
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
const backups = (home = path.join(tmpDir, 'home')) => {
|
||||
const root = path.join(home, 'backups', 'skills');
|
||||
if (!fs.existsSync(root)) return [] as string[];
|
||||
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
|
||||
};
|
||||
|
||||
test('a foreign DIRECTORY symlink whose target has no SKILL.md is foreign, not unclaimed', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const userdir = path.join(tmpDir, 'userdir');
|
||||
fs.mkdirSync(userdir);
|
||||
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
|
||||
fs.symlinkSync(userdir, path.join(skillsDir, 'qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('skipped qa');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(userdir);
|
||||
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('flip cleanup moves a CUSTOMIZED banner copy to the backup root instead of deleting it', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: gstack-qa\n---\n${BANNER}\n# customized\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), custom);
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
const saved = backups();
|
||||
expect(saved.length).toBe(1);
|
||||
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
|
||||
});
|
||||
|
||||
test('when the backup root cannot be created, the customized file stays and the entry is reported', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: qa\n---\n${BANNER}\n# customized\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(tmpDir, 'home'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'home', 'backups'), 'not a dir');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('could not back up');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
});
|
||||
|
||||
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, 'gstack-qa', 'sections'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'my-notes.md'), 'mine\n');
|
||||
fs.symlinkSync(path.join(tmpDir, 'my-notes.md'), path.join(skillsDir, 'gstack-qa', 'notes.md'));
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'sections'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'gstack-qa', 'notes.md'))).toBe(path.join(tmpDir, 'my-notes.md'));
|
||||
});
|
||||
|
||||
test('the root alias marker is written only for a directory relink creates', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), `---\nname: gstack\n---\n${BANNER}\n# root\n`);
|
||||
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
|
||||
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'mine\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
|
||||
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* corrupted the generated SKILL.md — the source files must stay byte-intact.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -52,7 +52,18 @@ beforeAll(() => {
|
||||
'SKILL_PREFIX=0',
|
||||
'QUIET=1',
|
||||
'_WINDOWS_COPY_NOTE_PRINTED=1',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
`SOURCE_GSTACK_DIR="${ROOT}"`,
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-alias-test-backups"`,
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_print_windows_copy_note_once'),
|
||||
extractFn('_link_skill_runtime_assets'),
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
@@ -63,7 +74,7 @@ beforeAll(() => {
|
||||
installOnce,
|
||||
installOnce,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
|
||||
const result = runBashScript(script, { timeout: 60_000 });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
@@ -144,12 +155,20 @@ describe('alias installs are rewritten copies (#2511, #2201)', () => {
|
||||
const script = [
|
||||
'set -e',
|
||||
'IS_WINDOWS=0',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
`SOURCE_GSTACK_DIR="${ROOT}"`,
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_install_alias_skill_md'),
|
||||
extractFn('link_claude_root_skill_alias'),
|
||||
`link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const aliasSkill = path.join(aliasDir, 'SKILL.md');
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* setup: the browser lines of the final summary. Aside (aside.com, macOS 15+)
|
||||
* is the primary driver; the compiled browse binary is the fallback.
|
||||
*
|
||||
* Since the Chromium bootstrap became best-effort (#2802, _PW_FAIL_REASON),
|
||||
* two places must consult that reason so they never promise a bundled browser
|
||||
* that cannot launch, and never tell an Aside user their browser skills are
|
||||
* gone when only the fallback is missing:
|
||||
* - _browser_hint, the one-line "browser:" hint under every host's
|
||||
* "gstack ready" block;
|
||||
* - the Chromium bootstrap summary printed last.
|
||||
* Behavior fixture: extract the code from setup and run it with the Aside
|
||||
* probe stubbed and the reason set or empty.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
|
||||
// The reason branch of the final summary, up to (not including) the
|
||||
// foreign-entries report that follows it.
|
||||
function summaryReasonBlock(): string {
|
||||
const start = SETUP_SRC.indexOf('# ─── Chromium bootstrap summary');
|
||||
const end = SETUP_SRC.indexOf('if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]}', start);
|
||||
if (start < 0 || end < 0) throw new Error('Could not locate the Chromium bootstrap summary block in setup');
|
||||
return SETUP_SRC.slice(start, end);
|
||||
}
|
||||
|
||||
// `command -v aside` is the only probe either site makes; shadow the builtin
|
||||
// so the test never depends on whether the machine running it has Aside.
|
||||
const COMMAND_SHADOW = 'command() { if [ "$1" = "-v" ] && [ "$2" = "aside" ]; then [ "$ASIDE_PRESENT" = "1" ]; else builtin command "$@"; fi; }';
|
||||
|
||||
function runBash(lines: string[]): string {
|
||||
const r = spawnSync('bash', ['-c', lines.join('\n')], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.stderr).toBe('');
|
||||
expect(r.status).toBe(0);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
function runHint(opts: { aside: boolean; reason: string }): string {
|
||||
return runBash([
|
||||
'set -e',
|
||||
'log() { echo "$@"; }',
|
||||
`ASIDE_PRESENT=${opts.aside ? 1 : 0}`,
|
||||
COMMAND_SHADOW,
|
||||
`_PW_FAIL_REASON=${JSON.stringify(opts.reason)}`,
|
||||
extractFn('_browser_hint'),
|
||||
'_browser_hint',
|
||||
]);
|
||||
}
|
||||
|
||||
function runSummary(opts: { aside: boolean; reason: string }): string {
|
||||
return runBash([
|
||||
'set -e',
|
||||
'log() { echo "$@"; }',
|
||||
`ASIDE_PRESENT=${opts.aside ? 1 : 0}`,
|
||||
COMMAND_SHADOW,
|
||||
'SOURCE_GSTACK_DIR=/nonexistent-gstack-dir', // no telemetry binary → the event is skipped
|
||||
`_PW_FAIL_REASON=${JSON.stringify(opts.reason)}`,
|
||||
summaryReasonBlock(),
|
||||
'echo REACHED_END=1',
|
||||
]);
|
||||
}
|
||||
|
||||
describe('setup: _browser_hint', () => {
|
||||
test('static pin: the hint reads _PW_FAIL_REASON', () => {
|
||||
expect(extractFn('_browser_hint')).toContain('_PW_FAIL_REASON');
|
||||
});
|
||||
|
||||
test('Aside present, bootstrap fine → Aside primary with the bundled fallback', () => {
|
||||
const out = runHint({ aside: true, reason: '' });
|
||||
expect(out).toContain('browser: Aside (primary) — gstack browser is the fallback');
|
||||
});
|
||||
|
||||
test('Aside present, bootstrap failed → Aside primary, fallback named unavailable with the reason', () => {
|
||||
const out = runHint({ aside: true, reason: 'chromium-install-timeout' });
|
||||
expect(out).toContain('Aside (primary)');
|
||||
expect(out).toContain('fallback unavailable');
|
||||
expect(out).toContain('chromium-install-timeout');
|
||||
expect(out).not.toContain('gstack browser is the fallback');
|
||||
});
|
||||
|
||||
test('Aside absent, bootstrap fine → bundled browser is the fallback, Aside suggested', () => {
|
||||
const out = runHint({ aside: false, reason: '' });
|
||||
expect(out).toContain('browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)');
|
||||
});
|
||||
|
||||
test('Aside absent, bootstrap failed → no browser promised; reason and both remedies named', () => {
|
||||
const out = runHint({ aside: false, reason: 'chromium-install,post-install-launch' });
|
||||
expect(out).toContain('browser: none available');
|
||||
expect(out).toContain('chromium-install,post-install-launch');
|
||||
expect(out).toContain('install Aside');
|
||||
expect(out).toContain('re-run ./setup');
|
||||
expect(out).not.toContain('gstack browser (fallback)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup: Chromium bootstrap summary is Aside-aware', () => {
|
||||
test('Aside present → skills keep running in Aside, only the fallback is missing, /pair-agent excepted', () => {
|
||||
const out = runSummary({ aside: true, reason: 'chromium-install' });
|
||||
expect(out).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install)');
|
||||
expect(out).toContain('Aside is installed');
|
||||
expect(out).toContain('only their bundled fallback is missing');
|
||||
expect(out).toContain('/pair-agent needs the bundled browser itself');
|
||||
expect(out).not.toContain('Skills that need it:');
|
||||
expect(out).toContain('REACHED_END=1');
|
||||
});
|
||||
|
||||
test('Aside absent → the pre-Aside wording: the skills need the bundled browser', () => {
|
||||
const out = runSummary({ aside: false, reason: 'chromium-install' });
|
||||
expect(out).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install)');
|
||||
expect(out).toContain('Skills that need it:');
|
||||
for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
|
||||
expect(out).toContain(skill);
|
||||
}
|
||||
expect(out).not.toContain('Aside is installed');
|
||||
expect(out).toContain('REACHED_END=1');
|
||||
});
|
||||
|
||||
test('no failure → the reason branch prints nothing', () => {
|
||||
const out = runSummary({ aside: true, reason: '' });
|
||||
expect(out).not.toContain('Browser unavailable');
|
||||
expect(out).toContain('REACHED_END=1');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -9,7 +9,7 @@ const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
// Run a bash snippet, return {stdout, stderr, status}.
|
||||
function runBash(script: string): { stdout: string; stderr: string; status: number } {
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const r = runBashScript(script, { timeout: 30_000 });
|
||||
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* down to uselessness.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -66,10 +66,23 @@ beforeAll(() => {
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_print_windows_copy_note_once'),
|
||||
extractFn('_link_skill_runtime_assets'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
|
||||
const result = runBashScript(script, { timeout: 60_000 });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
@@ -138,11 +151,14 @@ describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', (
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden files are not installed', () => {
|
||||
test('hidden files are not installed (gstack\'s own provenance marker is the one allowed dotfile)', () => {
|
||||
for (const skill of installedSkillDirs()) {
|
||||
const hidden = fs
|
||||
.readdirSync(path.join(installDir, skill))
|
||||
.filter((e) => e.startsWith('.'));
|
||||
.filter((e) => e.startsWith('.'))
|
||||
// .gstack-owned is written by the linker for directories it creates (#2119),
|
||||
// not copied from the skill source; every other dotfile must stay out.
|
||||
.filter((e) => e !== '.gstack-owned');
|
||||
expect(hidden).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* skills must stay.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -24,7 +24,7 @@ function extractFn(name: string): string {
|
||||
}
|
||||
|
||||
function cleanupBody(): string {
|
||||
return extractFn('cleanup_old_claude_symlinks');
|
||||
return [extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/cleanup-orphans-backups-${process.pid}"`, extractFn('cleanup_old_claude_symlinks')].join('\n');
|
||||
}
|
||||
|
||||
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
|
||||
@@ -68,13 +68,11 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${tmp}/backups"`,
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 5000 });
|
||||
const names = fs.existsSync(skills)
|
||||
? fs.readdirSync(skills).sort()
|
||||
: [];
|
||||
@@ -259,21 +257,65 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows real-file leftover is removed when the payload still names it', () => {
|
||||
// #2119: a bare name match used to delete a USER's own skill that happened to
|
||||
// share a gstack skill name. Provenance must be proven: the .gstack-owned
|
||||
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
|
||||
// AUTO-GENERATED header (legacy copies made before the marker existed).
|
||||
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
|
||||
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ship\n';
|
||||
const r = runCleanup({
|
||||
isWindows: '1',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'qa');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
plantUserSkill(skills, 'qa');
|
||||
for (const name of ['qa', 'ship', 'review', 'browse']) {
|
||||
fs.mkdirSync(path.join(payload, name));
|
||||
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
|
||||
}
|
||||
// Byte-identical copy of the payload source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
||||
// Legacy copy carrying the generated header but drifted from source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'ship'));
|
||||
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
|
||||
// Marker-carrying copy with arbitrary content → ours.
|
||||
fs.mkdirSync(path.join(skills, 'browse'));
|
||||
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
|
||||
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
|
||||
// The user's OWN skill that shares a gstack name → foreign, must survive.
|
||||
plantUserSkill(skills, 'review');
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'my-own']);
|
||||
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The Windows arm is the ONLY path that touches a real-file SKILL.md. On
|
||||
// Unix a same-name real-file skill must survive even when the payload names
|
||||
// it and even when its bytes are identical to the payload source.
|
||||
test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => {
|
||||
const r = runCleanup({
|
||||
isWindows: '0',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
fs.mkdirSync(path.join(payload, 'qa'));
|
||||
fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skills, 'qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
||||
fs.mkdirSync(path.join(skills, 'ship'));
|
||||
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), '');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.names).toEqual(['gstack', 'qa', 'ship']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -85,7 +86,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('SKIP');
|
||||
// No child symlink leaked.
|
||||
@@ -120,7 +121,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('LINKED');
|
||||
expect(fs.lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
@@ -159,7 +160,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('LINKED');
|
||||
expect(fs.readlinkSync(dest)).toBe(source);
|
||||
@@ -191,7 +192,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
fi
|
||||
echo "skip=$_SKIP_CLAUDE_REGISTER"
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('skip=0');
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -137,11 +137,7 @@ describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior
|
||||
'echo "INSTALLED=$EMOJI_FONT_INSTALLED"',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 10000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
const out = result.stdout ?? '';
|
||||
return {
|
||||
exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1),
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* setup never links over, copies over, or deletes a skill it does not own
|
||||
* (#2119). The relink gate alone was not enough: link_claude_skill_dirs runs
|
||||
* BEFORE relink on every ./setup, and on Linux `ln -snf` replaces a user's
|
||||
* real SKILL.md with a symlink into gstack (on Windows: rm -rf + cp, then a
|
||||
* marker that makes the user's dir "ours" on the next flip). The reverse
|
||||
* mode-flip cleanup (cleanup_prefixed_claude_symlinks) kept a bare name-match
|
||||
* deletion and a `*gstack*` substring match. Same anchor-sliced convention as
|
||||
* test/setup-cleanup-orphans.test.ts.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
const HELPERS = [
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_link_skill_runtime_assets() { :; }',
|
||||
'_print_windows_copy_note_once() { :; }',
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
].join('\n');
|
||||
|
||||
const FOREIGN = '---\nname: qa\ndescription: mine\n---\n# not gstack\n';
|
||||
const GENERATED = (name: string) => `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`;
|
||||
|
||||
function mkTree(): { tmp: string; skills: string; payload: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-own-'));
|
||||
const skills = path.join(tmp, 'skills');
|
||||
const payload = path.join(skills, 'gstack');
|
||||
fs.mkdirSync(payload, { recursive: true });
|
||||
return { tmp, skills, payload };
|
||||
}
|
||||
function bash(lines: string[], tmp: string) {
|
||||
const r = runBashScript(lines.join('\n'), { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') } });
|
||||
// An extracted function calling a helper this harness forgot to extract must
|
||||
// fail loudly, not degrade into "foreign, skipped".
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: link_claude_skill_dirs never links over a foreign skill (#2119)', () => {
|
||||
for (const isWindows of ['0', '1'] as const) {
|
||||
test(`IS_WINDOWS=${isWindows}: a foreign real SKILL.md survives byte-identical, gets no marker, is reported and counted`, () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), FOREIGN);
|
||||
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, 'SKILL_PREFIX=0', HELPERS,
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
const md = path.join(t.skills, 'qa', 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
expect(r.stderr).toContain('skipped qa');
|
||||
expect(r.stdout).toContain('FOREIGN=qa');
|
||||
// The other skill still links normally.
|
||||
expect(fs.existsSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(true);
|
||||
expect(r.stdout).toContain('linked skills: ship');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('our own previous entry (symlink into the payload) is refreshed, not skipped', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the Windows marker records the owning payload path and a marked copy is ours on the next run', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const first = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(first.status).toBe(0);
|
||||
const marker = path.join(t.skills, 'qa', '.gstack-owned');
|
||||
expect(fs.readFileSync(marker, 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
|
||||
// Second run over our own copy: refreshed, not reported.
|
||||
const second = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(second.stdout).toContain('FOREIGN=\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: _install_alias_skill_md never overwrites a foreign alias-named skill (#2119)', () => {
|
||||
test('a user skill named connect-chrome survives; a generated alias copy is refreshed', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'open-gstack-browser'));
|
||||
fs.writeFileSync(path.join(t.payload, 'open-gstack-browser', 'SKILL.md'), GENERATED('open-gstack-browser'));
|
||||
fs.mkdirSync(path.join(t.skills, 'connect-chrome'));
|
||||
fs.writeFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-connect-chrome'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), GENERATED('gstack-connect-chrome').replace('# gstack-connect-chrome', '# old alias copy'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/connect-chrome" connect-chrome`,
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/gstack-connect-chrome" gstack-connect-chrome`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(r.stdout).toContain('FOREIGN=connect-chrome');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).toContain('name: gstack-connect-chrome');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).not.toContain('old alias copy');
|
||||
// A pre-existing alias directory is never stamped (we did not create it); a created one is.
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-connect-chrome', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'connect-chrome', '.gstack-owned'))).toBe(false);
|
||||
const created = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/fresh-alias" fresh-alias`], t.tmp);
|
||||
expect(created.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'fresh-alias', '.gstack-owned'), 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: cleanup_prefixed_claude_symlinks proves provenance (#2119)', () => {
|
||||
function runFlip(isWindows: '0' | '1', plant: (skills: string, payload: string) => void) {
|
||||
const t = mkTree();
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
plant(t.skills, t.payload);
|
||||
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
const names = fs.readdirSync(t.skills).sort();
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
return { ...r, names };
|
||||
}
|
||||
|
||||
test('Windows: a user-owned gstack-qa (no marker, not identical, no header) survives the prefix→flat flip', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# user-owned\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
expect(r.stdout).toBe('');
|
||||
});
|
||||
|
||||
test('Windows: marker, byte-identical, and generated-header copies are reaped', () => {
|
||||
const r = runFlip('1', (skills, payload) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(r.names).toEqual(['gstack']);
|
||||
const m = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# stale\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '');
|
||||
});
|
||||
expect(m.names).toEqual(['gstack']);
|
||||
const h = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# older render'));
|
||||
});
|
||||
expect(h.names).toEqual(['gstack']);
|
||||
});
|
||||
|
||||
test('Windows: weak proof (banner, no marker) removes only SKILL.md; the user\'s other files and the directory survive', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# started from gstack, then customized'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
expect(r.stdout).toContain('cleaned gstack-qa/SKILL.md');
|
||||
// Strong proof (marker) still removes the directory we created.
|
||||
const m = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '# stale\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'sections', 'x.md'), 'x\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '/payload\n');
|
||||
});
|
||||
expect(m.names).toEqual(['gstack']);
|
||||
});
|
||||
|
||||
test('Windows: a ONE-line AUTO-GENERATED substring from another generator is not provenance — the entry survives', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-skill-builder -->\n# theirs\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
});
|
||||
|
||||
test('a SKILL.md symlink whose target merely CONTAINS the substring gstack is not reaped; an anchored gstack/ segment is', () => {
|
||||
const keep = runFlip('0', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(keep.names).toEqual(['gstack', 'gstack-qa']);
|
||||
const reap = runFlip('0', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(reap.names).toEqual(['gstack']);
|
||||
expect(reap.stdout).toContain('cleaned up prefixed entries: gstack-qa');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: weakly-proven files are moved aside, never overwritten (#2119 review)', () => {
|
||||
test('Linux linker: a customized banner copy is moved to the backup root before the symlink lands; an identical copy is not', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
const custom = GENERATED('qa').replace('# qa', '# my qa, started from gstack');
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.copyFileSync(path.join(t.payload, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
|
||||
'echo "BACKED=${_BACKED_UP_SKILL_MDS[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
// Weakly-proven, pre-existing: no marker (it must never become deletable whole). Created dirs do get one.
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship'))).toBe(false);
|
||||
expect(r.stdout).toContain('BACKED=qa\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows flip: weak proof leaves the user\'s files in place', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=1', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('_run_relink_quiet forwards relink\'s skipped and moved lines once, deduped against setup\'s own report', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
const fake = path.join(t.tmp, 'fake-relink');
|
||||
fs.writeFileSync(fake, '#!/usr/bin/env bash\necho "linked 3 skills"\necho " skipped qa: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho " skipped ship: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho "Moved 1 pre-existing SKILL.md file(s) to /x before linking gstack\'s: review"\n');
|
||||
fs.chmodSync(fake, 0o755);
|
||||
const r = bash(['set -e', '_FOREIGN_SKIPPED_ENTRIES=(qa)', `GSTACK_RELINK="${fake}"`, 'INSTALL_SKILLS_DIR=/dev/null', 'SOURCE_GSTACK_DIR=/dev/null',
|
||||
extractFn('_run_relink_quiet'), '_run_relink_quiet', 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr.match(/skipped ship/g)?.length).toBe(1);
|
||||
expect(r.stderr).not.toContain('skipped qa');
|
||||
expect(r.stderr).toContain('Moved 1 pre-existing');
|
||||
expect(r.stderr).not.toContain('linked 3 skills');
|
||||
expect(r.stdout).toContain('FOREIGN=qa ship\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: banner census, checkout naming, legacy linked dirs, markers (#2119 review)', () => {
|
||||
test('every generated SKILL.md in this tree passes _gstack_generated_header (four carry the banner past line 40)', () => {
|
||||
const files = fs.readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => path.join(ROOT, d.name, 'SKILL.md'))
|
||||
.concat([path.join(ROOT, 'SKILL.md')])
|
||||
.filter((f) => fs.existsSync(f) && fs.readFileSync(f, 'utf-8').includes('<!-- AUTO-GENERATED from'));
|
||||
expect(files.length).toBeGreaterThan(30);
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-census-'));
|
||||
try {
|
||||
const r = bash(['set -e', extractFn('_gstack_generated_header'),
|
||||
`for f in ${files.map((f) => `"${f}"`).join(' ')}; do _gstack_generated_header "$f" || echo "MISS $f"; done`], tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a link into a checkout named without a gstack segment is ours when the tree carries setup + VERSION + bin/', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const other = path.join(t.tmp, 'gs-feature');
|
||||
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
|
||||
fs.mkdirSync(path.join(other, 'bin'));
|
||||
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
|
||||
// A hand-written skill repo that happens to carry VERSION + setup + bin/ is NOT a gstack tree.
|
||||
const mine = path.join(t.tmp, 'myskills');
|
||||
fs.mkdirSync(path.join(mine, 'ship'), { recursive: true });
|
||||
fs.mkdirSync(path.join(mine, 'bin'));
|
||||
fs.writeFileSync(path.join(mine, 'VERSION'), '0.1\n');
|
||||
fs.writeFileSync(path.join(mine, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(mine, 'ship', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.symlinkSync(path.join(mine, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=ship\n');
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(path.join(mine, 'ship', 'SKILL.md'));
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'qa', 'SKILL.md'))).toBe(path.join(t.payload, 'qa', 'SKILL.md'));
|
||||
// Re-pointed, but the directory pre-existed: no marker (only directories we create get one).
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux linker: a created directory gets the marker; a pre-existing unclaimed directory (user files, no SKILL.md) is linked into without one', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
for (const n of ['qa', 'ship']) { fs.mkdirSync(path.join(t.payload, n)); fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n)); }
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'notes.md'), 'mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=\n');
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(t.skills, 'ship', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('flip on a legacy linked dir (no marker): all-links dir removed whole; a dir with a user file keeps the file, loses our links', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
for (const [name, src] of [['gstack-qa', 'qa'], ['gstack-ship', 'ship']] as const) {
|
||||
fs.mkdirSync(path.join(t.skills, name));
|
||||
fs.symlinkSync(path.join(t.payload, src, 'SKILL.md'), path.join(t.skills, name, 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, name, 'sections'));
|
||||
}
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'keep\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'sections'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
|
||||
expect(r.stdout).toContain('cleaned gstack-ship/SKILL.md');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: cycle-3 hardening — assets, foreign dir links, failed backups, flip backups (#2119 review)', () => {
|
||||
const REAL_ASSETS = extractFn('_link_skill_runtime_assets');
|
||||
|
||||
test('an unclaimed or weakly-owned directory keeps the user\'s same-named real assets; a created directory gets ours', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
for (const n of ['qa', 'ship', 'review']) {
|
||||
fs.mkdirSync(path.join(t.payload, n, 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n));
|
||||
fs.writeFileSync(path.join(t.payload, n, 'templates', 'ours.md'), 'ours\n');
|
||||
fs.writeFileSync(path.join(t.payload, n, 'checklist.md'), 'ours\n');
|
||||
}
|
||||
// qa: unclaimed (no SKILL.md) with the user's templates/ and checklist.md
|
||||
fs.mkdirSync(path.join(t.skills, 'qa', 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'mine\n');
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'my checklist\n');
|
||||
// ship: weakly owned (customized banner copy) with the user's templates/
|
||||
fs.mkdirSync(path.join(t.skills, 'ship', 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'SKILL.md'), GENERATED('ship').replace('# ship', '# customized'));
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, REAL_ASSETS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'utf-8')).toBe('my checklist\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'checklist.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
|
||||
expect(r.stderr).toContain('kept qa/templates');
|
||||
expect(r.stderr).toContain('kept qa/checklist.md');
|
||||
expect(r.stderr).toContain('kept ship/templates');
|
||||
// review: created by us → assets linked
|
||||
expect(fs.lstatSync(path.join(t.skills, 'review', 'templates')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'review', 'checklist.md')).isSymbolicLink()).toBe(true);
|
||||
// ship's customized SKILL.md was backed up, its assets kept, its checklist (absent before) linked
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship', 'SKILL.md'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'checklist.md')).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a foreign DIRECTORY symlink (target has no SKILL.md) is foreign, not unclaimed: the user\'s link survives', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const userdir = path.join(t.tmp, 'userdir');
|
||||
fs.mkdirSync(userdir);
|
||||
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
|
||||
fs.symlinkSync(userdir, path.join(t.skills, 'qa'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=qa\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'qa'))).toBe(userdir);
|
||||
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('when the backup cannot be written, the customized file is left untouched and the entry is reported, never overwritten', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const custom = GENERATED('qa').replace('# qa', '# customized');
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
|
||||
fs.writeFileSync(path.join(t.tmp, 'not-a-dir'), 'x');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, `_SKILL_BACKUP_ROOT="${t.tmp}/not-a-dir/backups"`, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
expect(r.stderr).toContain('could not back up');
|
||||
expect(r.stdout).toContain('FOREIGN=qa\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows flip: a customized banner copy is moved to the backup root, not deleted; an alias-shaped copy (only name: differs) is just removed', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
const custom = GENERATED('gstack-qa').replace('# gstack-qa', '# customized on windows');
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-ship'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'), GENERATED('ship').replace('name: ship', 'name: gstack-ship'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=1', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-ship'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays, the dir stays', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'gstack-qa', 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, 'gstack-qa', 'sections'));
|
||||
fs.writeFileSync(path.join(t.tmp, 'my-notes.md'), 'mine\n');
|
||||
fs.symlinkSync(path.join(t.tmp, 'my-notes.md'), path.join(t.skills, 'gstack-qa', 'notes.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'sections'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'gstack-qa', 'notes.md'))).toBe(path.join(t.tmp, 'my-notes.md'));
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* setup: Chromium bootstrap is best-effort and bounded (#1900, #1901, #1902,
|
||||
* #913, #2233).
|
||||
*
|
||||
* Before: `set -e` plus a bare `bunx playwright install chromium` (and an
|
||||
* explicit `exit 1` after the post-install probe) sat in section "# 2", ahead
|
||||
* of "# 4. Install for Claude". An offline, proxied, or AppArmor-restricted
|
||||
* box ended with ZERO skills registered, and a wedged download hung setup
|
||||
* forever. Now every failure records a reason code in _PW_FAIL_REASON, the
|
||||
* install is deadline-bounded, lock contention is a reason (not a fatal), and
|
||||
* skill registration always runs.
|
||||
*
|
||||
* Two layers, following test/setup-emoji-font.test.ts's convention:
|
||||
* 1. static invariants over the anchor-sliced block (line-number agnostic);
|
||||
* 2. an integration harness that executes the REAL block with stubbed
|
||||
* probe/installer so the exit path and reason codes are exercised.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
const BLOCK_START = "# 2. Ensure Playwright's Chromium is available";
|
||||
const BLOCK_END = '# 2b. Ensure a color-emoji font';
|
||||
|
||||
function slice(startAnchor: string, endAnchor: string): string {
|
||||
const start = SETUP_SRC.indexOf(startAnchor);
|
||||
const end = SETUP_SRC.indexOf(endAnchor, start);
|
||||
if (start < 0 || end < 0) throw new Error(`anchor not found: ${startAnchor} .. ${endAnchor}`);
|
||||
return SETUP_SRC.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
|
||||
const block = slice(BLOCK_START, BLOCK_END);
|
||||
const codeLines = block.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
||||
|
||||
describe('setup: Chromium bootstrap static invariants', () => {
|
||||
test('no exit inside the bootstrap block (skills must always register)', () => {
|
||||
// The only `exit` allowed is inside the INT/TERM trap string (Ctrl-C must
|
||||
// still terminate setup after killing the installer); a bare statement is not.
|
||||
const statements = codeLines.split('\n').filter((l) => !/^\s*trap /.test(l)).join('\n');
|
||||
expect(statements).not.toMatch(/\bexit 1\b/);
|
||||
expect(statements).not.toMatch(/\bexit\b/);
|
||||
expect(codeLines).toMatch(/trap '_kill_tree "\$_PW_PID".*exit 130' INT TERM/);
|
||||
expect(codeLines).toContain('trap - INT TERM');
|
||||
});
|
||||
|
||||
test('every failure arm records a reason code', () => {
|
||||
for (const code of [
|
||||
'skipped', 'chromium-install', 'chromium-install-timeout', 'chromium-install-locked',
|
||||
'windows-no-node', 'windows-node-modules', 'post-install-launch',
|
||||
]) {
|
||||
expect(codeLines).toContain(`_pw_fail ${code} `);
|
||||
}
|
||||
});
|
||||
|
||||
test('the download is deadline-bounded through the shared helper and env knob', () => {
|
||||
expect(codeLines).toContain('_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT"');
|
||||
expect(codeLines).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT');
|
||||
// Non-numeric and 0 fall back to the default instead of breaking arithmetic
|
||||
// or killing the install on the first poll.
|
||||
expect(codeLines).toMatch(/case "\$_PW_INSTALL_TIMEOUT" in ''\|\*\[!0-9\]\*\)/);
|
||||
expect(codeLines).toContain('_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))');
|
||||
expect(codeLines).toContain('[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600');
|
||||
expect(codeLines).toContain('[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600');
|
||||
});
|
||||
|
||||
test('lock contention is a reason code, not a fatal', () => {
|
||||
expect(codeLines).toContain('_pw_fail chromium-install-locked');
|
||||
expect(block).toContain('GSTACK_SKIP_PLAYWRIGHT');
|
||||
});
|
||||
|
||||
test('the lock EXIT trap still chains cleanup_copied_bun and is restored', () => {
|
||||
expect(codeLines).toContain("trap 'rm -rf \"$_PW_LOCK\" 2>/dev/null || true; cleanup_copied_bun' EXIT");
|
||||
expect(codeLines).toContain('trap cleanup_copied_bun EXIT');
|
||||
});
|
||||
|
||||
test('the daemon font refresh is skipped when Chromium is unavailable', () => {
|
||||
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
|
||||
expect(emoji).toContain('elif [ -z "$_PW_FAIL_REASON" ]; then');
|
||||
expect(emoji).toContain('refresh_browse_daemon_for_fonts');
|
||||
});
|
||||
|
||||
test('the final summary names the affected skills and the reason', () => {
|
||||
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('Chromium bootstrap summary'));
|
||||
expect(tail).toContain('Browser unavailable');
|
||||
for (const skill of ['/qa', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
|
||||
expect(tail).toContain(skill);
|
||||
}
|
||||
expect(tail).toContain('$_PW_FAIL_REASON');
|
||||
expect(tail).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(tail).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration harness: the real block + the real deadline helpers, with the
|
||||
* probe and installer stubbed. `bunx` is a shell function, so the block's
|
||||
* subshell inherits the stub. Emits REASON=... and REACHED_END=1 so the test
|
||||
* can prove setup continued past the block.
|
||||
*/
|
||||
function runBlock(opts: {
|
||||
probe: 'ok' | 'fail' | 'fail-then-ok'; // fail-then-ok = fresh install: probe fails, install runs, probe passes
|
||||
bunx: string; // body of the bunx stub
|
||||
env?: Record<string, string>;
|
||||
preLockPid?: string; // pre-create the install lock held by this pid
|
||||
preLockNoPidAgeMin?: number; // pre-create a lock dir with NO pid file, this many minutes old
|
||||
preLockAgeMin?: number; // with preLockPid: age the lock dir this many minutes
|
||||
markKill?: boolean; // record _kill_tree invocations to $MARK
|
||||
isWindows?: '0' | '1';
|
||||
prelude?: string; // extra shell lines (node/npm stubs) injected before the block
|
||||
platformOverride?: string; // value for _PLAYWRIGHT_PLATFORM_OVERRIDE (Ubuntu 26.04 path)
|
||||
}): { stdout: string; stderr: string; status: number; elapsedMs: number; tmp: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-block-'));
|
||||
const mark = path.join(tmp, 'mark');
|
||||
const killTree = opts.markKill
|
||||
? extractFn('_kill_tree').replace('_kill_tree() {', '_kill_tree_orig() {') +
|
||||
`\n_kill_tree() { echo killed >> "${mark}"; _kill_tree_orig "$1"; }\n`
|
||||
: extractFn('_kill_tree');
|
||||
if (opts.preLockPid) {
|
||||
const lock = path.join(tmp, 'gstack-playwright-install.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), opts.preLockPid);
|
||||
if (opts.preLockAgeMin !== undefined) {
|
||||
const t = new Date(Date.now() - opts.preLockAgeMin * 60_000);
|
||||
fs.utimesSync(lock, t, t);
|
||||
}
|
||||
}
|
||||
if (opts.preLockNoPidAgeMin !== undefined) {
|
||||
const lock = path.join(tmp, 'gstack-playwright-install.lock');
|
||||
fs.mkdirSync(lock);
|
||||
const t = new Date(Date.now() - opts.preLockNoPidAgeMin * 60_000);
|
||||
fs.utimesSync(lock, t, t);
|
||||
}
|
||||
const probeFn = opts.probe === 'fail-then-ok'
|
||||
? 'ensure_playwright_browser() { if [ -f "$MARK.probed" ]; then return 0; fi; : > "$MARK.probed"; return 1; }'
|
||||
: `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`;
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
`SOURCE_GSTACK_DIR="${tmp}"`,
|
||||
`TMPDIR="${tmp}"`,
|
||||
`MARK="${mark}"`,
|
||||
`_PLAYWRIGHT_PLATFORM_OVERRIDE="${opts.platformOverride ?? ''}"`,
|
||||
// EXIT-trap witness: the block chains its lock trap onto cleanup_copied_bun
|
||||
// and must leave cleanup_copied_bun installed when it is done.
|
||||
'cleanup_copied_bun() { echo cleanup >> "$MARK.exit"; }',
|
||||
'trap cleanup_copied_bun EXIT',
|
||||
'_clear_playwright_quarantine() { :; }',
|
||||
probeFn,
|
||||
opts.prelude ?? '',
|
||||
`bunx() { echo "bunx-called override=${'$'}{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-unset}" >> "$MARK"; ${opts.bunx}; }`,
|
||||
killTree,
|
||||
extractFn('_wait_with_deadline'),
|
||||
// The block opens with the /etc/os-release probe that RESETS the override
|
||||
// variable; a test that injects an override must start after that probe.
|
||||
opts.platformOverride !== undefined ? slice('# Chromium is BEST-EFFORT', BLOCK_END) : block,
|
||||
'echo "REASON=$_PW_FAIL_REASON"',
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const scriptPath = path.join(tmp, 'block.sh');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
const t0 = Date.now();
|
||||
const r = spawnSync('bash', [scriptPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
env: { PATH: process.env.PATH ?? '', HOME: tmp, ...(opts.env ?? {}) },
|
||||
});
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, elapsedMs: Date.now() - t0, tmp };
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap block executes best-effort', () => {
|
||||
test('probe ok: no reason recorded, no install attempted', () => {
|
||||
const r = runBlock({ probe: 'ok', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
});
|
||||
|
||||
test('install exits non-zero: reason chromium-install, setup continues (was: exit 1 before any skill registered)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stderr).toContain('chromium-install');
|
||||
expect(r.stderr).toContain('exited 7');
|
||||
});
|
||||
|
||||
test('install hangs: killed at the deadline with reason chromium-install-timeout, tree kill recorded', () => {
|
||||
const r = runBlock({
|
||||
probe: 'fail', bunx: 'sleep 30', markKill: true,
|
||||
env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '1' },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install-timeout\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.elapsedMs).toBeLessThan(20_000);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
|
||||
}, 15_000);
|
||||
|
||||
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
});
|
||||
|
||||
test('lock held by a live process: reason chromium-install-locked, setup continues (was: exit 1)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid) });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install-locked\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// The installer must not have run under a foreign lock.
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
// And the foreign lock is left for its owner.
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(true);
|
||||
});
|
||||
|
||||
test('stale lock (dead pid) is reclaimed and the install proceeds', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: '4194305' /* above Linux's largest pid_max: never a live pid */ });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('reclaiming stale Chromium-install lock');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
});
|
||||
|
||||
test('a lock whose pid file is garbage (-1, abc, 0) is stale, not "locked": kill -0 -1 would signal everything and "succeed"', () => {
|
||||
for (const pid of ['-1', 'abc', '0']) {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: pid });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(r.stdout).not.toContain('chromium-install-locked');
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('a lock held by a LIVE pid but older than the install bound is reclaimed (holder past its deadline, or a recycled pid)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid), preLockAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(r.stdout).not.toContain('chromium-install-locked');
|
||||
expect(r.stderr).toContain('past the install bound');
|
||||
}, 15_000);
|
||||
|
||||
test('a lock dir with NO pid file is reclaimed once older than the install bound, and honored while fresh', () => {
|
||||
const old = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
|
||||
expect(old.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(old.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(old.stdout).not.toContain('chromium-install-locked');
|
||||
const fresh = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 0, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '600' } });
|
||||
expect(fresh.status).toBe(0);
|
||||
expect(fresh.stdout).toContain('REASON=chromium-install-locked\n');
|
||||
}, 15_000);
|
||||
|
||||
test('_kill_tree without pgrep on PATH still kills the grandchild (walks /proc)', () => {
|
||||
if (!fs.existsSync('/proc')) return;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-'));
|
||||
try {
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
for (const name of ['bash', 'sleep', 'awk']) {
|
||||
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
|
||||
if (real) fs.symlinkSync(real, path.join(bin, name));
|
||||
}
|
||||
const script = [
|
||||
`export PATH="${bin}"`,
|
||||
'hash -r',
|
||||
'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 0; }',
|
||||
extractFn('_kill_tree'),
|
||||
// two commands so bash forks a real subshell instead of exec-ing sleep directly
|
||||
'( sleep 30; true ) & pid=$!',
|
||||
'sleep 0.3',
|
||||
// find the sleep grandchild via /proc, the same way the fallback does
|
||||
'child=$(awk -v p="$pid" \'{ s=$0; sub(/^[^)]*\\) /, "", s); split(s, f, " "); if (f[2]==p) { print $1; exit } }\' /proc/[0-9]*/stat 2>/dev/null)',
|
||||
'[ -n "$child" ] || { echo "NO_CHILD"; exit 0; }',
|
||||
'_kill_tree "$pid"',
|
||||
'sleep 0.3',
|
||||
'if kill -0 "$child" 2>/dev/null; then echo "CHILD_ALIVE"; else echo "CHILD_DEAD"; fi',
|
||||
].join('\n');
|
||||
const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 });
|
||||
expect(r.stdout).toContain('CHILD_DEAD');
|
||||
expect(r.stdout).not.toContain('PGREP_PRESENT');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('install succeeds but the post-install probe fails: reason post-install-launch with the userns hint', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=post-install-launch\n');
|
||||
expect(r.stderr).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
});
|
||||
|
||||
test('GSTACK_SKIP_PLAYWRIGHT=1: reason skipped, installer never invoked (#913)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', env: { GSTACK_SKIP_PLAYWRIGHT: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=skipped\n');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** A PATH that carries the tools the block needs but NO node — `command -v`
|
||||
* also finds shell functions, so hiding node from PATH is the only faithful
|
||||
* way to stand in for a Windows box without Node.js. */
|
||||
function pathWithoutNode(): { bin: string; cleanup: () => void } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nonode-'));
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
for (const name of ['bash', 'mkdir', 'rm', 'sleep', 'cat', 'pgrep', 'grep', 'cut', 'tr', 'dirname', 'basename']) {
|
||||
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
|
||||
if (real) fs.symlinkSync(real, path.join(bin, name));
|
||||
}
|
||||
return { bin, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap block — fresh install, lock hygiene, override, Windows arms', () => {
|
||||
test('fresh install happy path: probe fails, install succeeds, re-probe passes → no reason, lock released, EXIT trap restored', () => {
|
||||
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// Installer ran exactly once (no override on a non-26.04 path).
|
||||
const mark = fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8');
|
||||
expect(mark.split('\n').filter((l) => l.startsWith('bunx-called')).length).toBe(1);
|
||||
expect(mark).toContain('override=unset');
|
||||
// The mkdir-mutex is released for the next setup...
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
|
||||
// ...and the chained trap was restored, so cleanup_copied_bun still fires at exit.
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
|
||||
});
|
||||
|
||||
test('failed install still releases the lock and keeps cleanup_copied_bun on the EXIT trap', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
|
||||
});
|
||||
|
||||
test('Ubuntu 26.04 override reaches the installer as PLAYWRIGHT_HOST_PLATFORM_OVERRIDE (#2101)', () => {
|
||||
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0', platformOverride: 'ubuntu24.04-x64' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('override=ubuntu24.04-x64');
|
||||
});
|
||||
|
||||
test('Windows without Node.js: reason windows-no-node, setup continues (was: exit 1), post-install probe skipped', () => {
|
||||
const { bin, cleanup } = pathWithoutNode();
|
||||
try {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', env: { PATH: bin } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=windows-no-node\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stderr).toContain('nodejs.org');
|
||||
// The install itself ran; only the Node verification failed.
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows with Node.js but npm cannot install playwright/@ngrok: reason windows-node-modules, setup continues', () => {
|
||||
const r = runBlock({
|
||||
probe: 'fail', bunx: 'exit 0', isWindows: '1',
|
||||
prelude: 'node() { return 1; }\nnpm() { echo "npm $*" >> "$MARK"; return 1; }',
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=windows-node-modules\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stdout).toContain('Windows detected');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('npm install --no-save playwright');
|
||||
});
|
||||
|
||||
test('Windows with Node.js loading Playwright but the launch probe failing: Windows-specific post-install-launch hint', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', prelude: 'node() { return 0; }' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=post-install-launch\n');
|
||||
expect(r.stderr).toContain('via Node.js');
|
||||
expect(r.stderr).toContain('oven-sh/bun#4253');
|
||||
// The Linux userns hint belongs to the other arm.
|
||||
expect(r.stderr).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
});
|
||||
});
|
||||
|
||||
/** The 2b emoji-font step: the daemon font refresh must only run when the
|
||||
* browser actually works — otherwise setup prints a second failure line. */
|
||||
function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr: string; status: number } {
|
||||
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
|
||||
const script = [
|
||||
'set -e',
|
||||
`_PW_FAIL_REASON="${reason}"`,
|
||||
`ensure_emoji_font() { return ${fontOk ? 0 : 1}; }`,
|
||||
'refresh_browse_daemon_for_fonts() { echo REFRESHED; }',
|
||||
emoji,
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000 });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
describe('setup: emoji-font daemon refresh is gated on Chromium availability', () => {
|
||||
test('font installed + Chromium usable → daemon refreshed; font installed + Chromium unavailable → no refresh; font missing → note, no refresh', () => {
|
||||
const usable = runEmojiStep('', true);
|
||||
expect(usable.status).toBe(0);
|
||||
expect(usable.stdout).toContain('REFRESHED');
|
||||
expect(usable.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const unavailable = runEmojiStep('chromium-install', true);
|
||||
expect(unavailable.status).toBe(0);
|
||||
expect(unavailable.stdout).not.toContain('REFRESHED');
|
||||
expect(unavailable.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const noFont = runEmojiStep('', false);
|
||||
expect(noFont.status).toBe(0);
|
||||
expect(noFont.stdout).not.toContain('REFRESHED');
|
||||
expect(noFont.stderr).toContain('could not auto-install a color-emoji font');
|
||||
expect(noFont.stdout).toContain('REACHED_END=1');
|
||||
});
|
||||
});
|
||||
|
||||
/** The final summary block, executed with a recording telemetry stub. */
|
||||
function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelude: string[] = []): { stdout: string; stderr: string; status: number; argv: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-summary-'));
|
||||
try {
|
||||
const argvFile = path.join(tmp, 'telemetry.argv');
|
||||
if (telemetry !== 'missing') {
|
||||
fs.mkdirSync(path.join(tmp, 'bin'));
|
||||
const stub = path.join(tmp, 'bin', 'gstack-telemetry-log');
|
||||
fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$@" >> "${argvFile}"\nexit ${telemetry === 'ok' ? 0 : 1}\n`);
|
||||
fs.chmodSync(stub, 0o755);
|
||||
}
|
||||
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('# ─── Chromium bootstrap summary'));
|
||||
const script = [
|
||||
'set -e',
|
||||
'QUIET=0',
|
||||
'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }',
|
||||
`SOURCE_GSTACK_DIR="${tmp}"`,
|
||||
`_PW_FAIL_REASON="${reason}"`,
|
||||
...prelude,
|
||||
tail,
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000 });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : '';
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv };
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap summary block executes', () => {
|
||||
test('names the reason and the affected skills; hints are reason-specific; telemetry gets the reason code only', () => {
|
||||
const timeout = runSummary('chromium-install-timeout', 'ok');
|
||||
expect(timeout.status).toBe(0);
|
||||
expect(timeout.stdout).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install-timeout)');
|
||||
for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
|
||||
expect(timeout.stdout).toContain(skill);
|
||||
}
|
||||
expect(timeout.stdout).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(timeout.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
// Reason code only, never a path or command line; --no-sweep keeps the
|
||||
// one-shot event from finalizing other sessions' pending markers.
|
||||
expect(timeout.argv.trim()).toBe('--event-type onboarding --skill _setup_playwright --outcome chromium-install-timeout --no-sweep');
|
||||
|
||||
const launch = runSummary('post-install-launch', 'ok');
|
||||
expect(launch.stdout).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
expect(launch.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(launch.argv).toContain('--outcome post-install-launch');
|
||||
|
||||
const offline = runSummary('chromium-install', 'ok');
|
||||
expect(offline.stdout).toContain('Browser unavailable');
|
||||
expect(offline.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
expect(offline.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
});
|
||||
|
||||
test('silent when Chromium is fine; a missing or failing telemetry binary never breaks the tail of setup', () => {
|
||||
const fine = runSummary('', 'ok');
|
||||
expect(fine.status).toBe(0);
|
||||
expect(fine.stdout).toBe('REACHED_END=1\n');
|
||||
expect(fine.argv).toBe('');
|
||||
|
||||
const missing = runSummary('chromium-install', 'missing');
|
||||
expect(missing.status).toBe(0);
|
||||
expect(missing.stdout).toContain('Browser unavailable');
|
||||
expect(missing.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const failing = runSummary('chromium-install', 'fail');
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stdout).toContain('REACHED_END=1');
|
||||
expect(failing.argv).toContain('--outcome chromium-install');
|
||||
});
|
||||
|
||||
test('the summary names foreign entries left untouched and customized SKILL.md files moved to the backup root', () => {
|
||||
const r = runSummary('', 'missing', ['_FOREIGN_SKIPPED_ENTRIES=(qa)', '_BACKED_UP_SKILL_MDS=(ship review)', '_SKILL_BACKUP_ROOT="/tmp/gstack-bk/20260904"']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('Not registered (a skill you own already uses the name; left untouched): qa');
|
||||
expect(r.stdout).toContain("Moved 2 customized SKILL.md file(s) to /tmp/gstack-bk/20260904 before installing gstack's: ship review");
|
||||
expect(r.stdout).not.toContain('Browser unavailable');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// Nothing to report → neither line.
|
||||
const quiet = runSummary('', 'missing');
|
||||
expect(quiet.stdout).not.toContain('Not registered');
|
||||
expect(quiet.stdout).not.toContain('Moved ');
|
||||
});
|
||||
|
||||
test('an explicit opt-out (skipped) is reported as a choice, not a failure, and sends no telemetry', () => {
|
||||
const skipped = runSummary('skipped', 'ok');
|
||||
expect(skipped.status).toBe(0);
|
||||
expect(skipped.stdout).toContain('Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)');
|
||||
expect(skipped.stdout).not.toContain('Browser unavailable');
|
||||
expect(skipped.stdout).not.toContain('Fix the cause');
|
||||
expect(skipped.argv).toBe('');
|
||||
});
|
||||
|
||||
test('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=0, 000, or a value past nine digits means the default, not kill-on-first-poll or unbounded', () => {
|
||||
for (const v of ['0', '000', '99999999999999999999', 'abc', '']) {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: v } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
}
|
||||
// A wedged installer under "000" is still bounded by the DEFAULT, so with a
|
||||
// 1s override written as "0001" it is killed and classified as a timeout.
|
||||
const bounded = runBlock({ probe: 'fail', bunx: 'sleep 30', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '0001' } });
|
||||
expect(bounded.stdout).toContain('REASON=chromium-install-timeout\n');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* .gstack-owned marker (#2119): Windows installs COPY SKILL.md, so there is no
|
||||
* symlink to readlink. link_claude_skill_dirs writes the marker; gstack-relink
|
||||
* and cleanup_old_claude_symlinks prove provenance by it instead of by name.
|
||||
*/
|
||||
function runLinker(opts: {
|
||||
isWindows: '0' | '1';
|
||||
payload: string[];
|
||||
plant?: (skills: string, payload: string) => void;
|
||||
}): { status: number; stdout: string; stderr: string; skills: string; payload: string; tmp: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-'));
|
||||
const skills = path.join(tmp, 'skills');
|
||||
const payload = path.join(skills, 'gstack');
|
||||
fs.mkdirSync(payload, { recursive: true });
|
||||
for (const name of opts.payload) {
|
||||
fs.mkdirSync(path.join(payload, name));
|
||||
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`);
|
||||
}
|
||||
opts.plant?.(skills, payload);
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows}`,
|
||||
'SKILL_PREFIX=0',
|
||||
// Keep the operator's real render dir out of the picture.
|
||||
`GSTACK_USER_RENDER_DIR="${path.join(tmp, 'no-render')}"`,
|
||||
'_link_skill_runtime_assets() { :; }',
|
||||
'_print_windows_copy_note_once() { :; }',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`link_claude_skill_dirs "${payload}" "${skills}"`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership marker for Windows copy installs (#2119)', () => {
|
||||
test('the marker is written beside a COPIED SKILL.md (IS_WINDOWS=1) and beside a symlinked one (IS_WINDOWS=0): a directory we created is ours on every platform', () => {
|
||||
const win = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
|
||||
try {
|
||||
expect(win.status).toBe(0);
|
||||
for (const name of ['qa', 'ship']) {
|
||||
const md = path.join(win.skills, name, 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.existsSync(path.join(win.skills, name, '.gstack-owned'))).toBe(true);
|
||||
}
|
||||
expect(win.stdout).toContain('linked skills: qa ship');
|
||||
} finally {
|
||||
fs.rmSync(win.tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const unix = runLinker({ isWindows: '0', payload: ['qa'] });
|
||||
try {
|
||||
expect(unix.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(unix.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(unix.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('marker writer → cleanup reader: copies the Windows linker marked are reaped on a mode flip; an unmarked same-name skill survives', () => {
|
||||
// Phase 1: a Windows install links qa + ship (copies + markers).
|
||||
const r = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(r.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(r.skills, 'ship', '.gstack-owned'))).toBe(true);
|
||||
// Phase 2: the payload now also ships `review`, and the user has their
|
||||
// OWN hand-written `review` (no marker, not byte-identical, no generated
|
||||
// header) plus an unrelated `my-own`. A mode flip runs the cleanup.
|
||||
fs.mkdirSync(path.join(r.payload, 'review'));
|
||||
fs.writeFileSync(path.join(r.payload, 'review', 'SKILL.md'), '---\nname: review\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# review\n');
|
||||
fs.mkdirSync(path.join(r.skills, 'review'));
|
||||
fs.writeFileSync(path.join(r.skills, 'review', 'SKILL.md'), '---\nname: review\n---\n# mine, hand-written\n');
|
||||
fs.mkdirSync(path.join(r.skills, 'my-own'));
|
||||
fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n');
|
||||
const flip = runBashScript([
|
||||
'set -e', 'IS_WINDOWS=1',
|
||||
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`,
|
||||
].join('\n'), { timeout: 10_000 });
|
||||
expect(flip.status).toBe(0);
|
||||
expect(flip.stdout).toContain('cleaned up old entries:');
|
||||
expect(flip.stdout).toContain('qa');
|
||||
expect(flip.stdout).toContain('ship');
|
||||
expect(flip.stdout).not.toContain('review');
|
||||
expect(fs.readdirSync(r.skills).sort()).toEqual(['gstack', 'my-own', 'review']);
|
||||
expect(fs.readFileSync(path.join(r.skills, 'review', 'SKILL.md'), 'utf-8')).toContain('mine, hand-written');
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@
|
||||
* gen-skill-docs never deletes stale out-dir entries; setup is the one place
|
||||
* every host install passes through. Behavior fixture: extract the helper and
|
||||
* its gate from setup, run it against a temp tree.
|
||||
*
|
||||
* Ownership (#2119): a symlink into our render tree goes outright; a REAL
|
||||
* host directory proven only by the generated banner is weak proof, so only
|
||||
* our SKILL.md, marker and asset links are removed (_cleanup_weak_dir) and the
|
||||
* user's own files next to them survive.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
@@ -36,7 +41,7 @@ function mk(t: string) {
|
||||
fs.writeFileSync(path.join(src, s, 'SKILL.md.tmpl'), 'x');
|
||||
}
|
||||
// Generated tree: live renders + two retired ones + the gstack sidecar.
|
||||
for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack']) {
|
||||
for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack-extra', 'gstack']) {
|
||||
fs.mkdirSync(path.join(gen, g), { recursive: true });
|
||||
fs.writeFileSync(path.join(gen, g, 'SKILL.md'), `${BANNER}# ${g}\n`);
|
||||
}
|
||||
@@ -48,12 +53,23 @@ function mk(t: string) {
|
||||
fs.writeFileSync(path.join(host, 'gstack-gone', 'SKILL.md'), `${BANNER}copy\n`);
|
||||
fs.mkdirSync(path.join(host, 'gstack-mine'));
|
||||
fs.writeFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), '---\nname: mine\n---\nuser skill\n');
|
||||
// Bannered real copy of a retired render with the user's own file beside it:
|
||||
// weak proof covers only SKILL.md, so notes.md must survive (#2119).
|
||||
fs.mkdirSync(path.join(host, 'gstack-extra'));
|
||||
fs.writeFileSync(path.join(host, 'gstack-extra', 'SKILL.md'), `${BANNER}copy\n`);
|
||||
fs.writeFileSync(path.join(host, 'gstack-extra', 'notes.md'), 'my notes\n');
|
||||
return { src, gen, host };
|
||||
}
|
||||
|
||||
function runPrune(src: string, gen: string, host?: string) {
|
||||
const script = [
|
||||
'set -e',
|
||||
// _cleanup_weak_dir and the helpers it leans on come from main's ownership
|
||||
// gate; the prune routes bannered real dirs through it.
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_owned_for_windows_refresh'),
|
||||
extractFn('_prune_stale_generated'),
|
||||
`_prune_stale_generated "${src}" "${gen}" ${host ? `"${host}"` : ''}`,
|
||||
@@ -89,6 +105,12 @@ describe('setup: _prune_stale_generated', () => {
|
||||
expect(fs.existsSync(path.join(host, 'gstack-oldskill'))).toBe(false);
|
||||
expect(fs.lstatSync(path.join(host, 'gstack-oldskill'), { throwIfNoEntry: false })).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(host, 'gstack-gone'))).toBe(false);
|
||||
// Bannered copy with the user's own file next to it: only our SKILL.md
|
||||
// goes, the file and the directory stay, and setup says so.
|
||||
expect(r.stdout).toContain('pruned retired skill: gstack-extra');
|
||||
expect(r.stdout).toContain('cleaned gstack-extra/SKILL.md (other files in that directory were left in place)');
|
||||
expect(fs.existsSync(path.join(host, 'gstack-extra', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(host, 'gstack-extra', 'notes.md'), 'utf-8')).toBe('my notes\n');
|
||||
// Live link and the user's own (unbannered) dir: untouched.
|
||||
expect(fs.lstatSync(path.join(host, 'gstack-qa')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), 'utf-8')).toContain('user skill');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -95,10 +95,7 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b
|
||||
const helper = extractHelper();
|
||||
// IS_WINDOWS must exist as a shell-readable var before sourcing.
|
||||
const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`;
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 5000 });
|
||||
const lst = fs.lstatSync(dst, { throwIfNoEntry: false });
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* factory/opencode can't silently regress.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -165,7 +165,7 @@ function runInstaller(
|
||||
...fns.map(extractFn),
|
||||
invocation,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* compares LIVE eval runs (tool calls, turns, cost); this one compares
|
||||
* static SKILL.md sizes. Both gate-tier.
|
||||
*
|
||||
* Baseline rebased v1.69.1.0 → v1.80.0.0: the Aside-first browser contract
|
||||
* Baseline rebased v1.69.1.0 → v1.81.0.0: the Aside-first browser contract
|
||||
* ({{ASIDE_SETUP}}) plus the gstack-browser fallback block now ride in every
|
||||
* browsing skill (~9KB), which pushed benchmark and scrape past 1.5× of the
|
||||
* v1.69.1.0 anchor. Deliberate, corpus-wide, receipted in the v1.80.0.0
|
||||
* v1.69.1.0 anchor. Deliberate, corpus-wide, receipted in the v1.81.0.0
|
||||
* CHANGELOG; the v1.69.1.0 fixture stays on disk for history.
|
||||
*
|
||||
* The previous baseline lived at test/fixtures/parity-baseline-v1.69.1.0.json,
|
||||
@@ -48,7 +48,7 @@ import { logBudgetOverride } from './helpers/budget-override';
|
||||
import { CARVED_SKILLS } from './helpers/carve-guards';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BASELINE_PATH = path.join(REPO_ROOT, 'test', 'fixtures', 'parity-baseline-v1.80.0.0.json');
|
||||
const BASELINE_PATH = path.join(REPO_ROOT, 'test', 'fixtures', 'parity-baseline-v1.81.0.0.json');
|
||||
|
||||
// Default per-skill ratio is 1.50 (50% growth tolerance). Adjusted v1.52.0.0
|
||||
// (cathedral cap audit) from 1.05 → 1.50: a 5% ratio tripped on legitimate
|
||||
@@ -68,11 +68,11 @@ interface Regression {
|
||||
}
|
||||
|
||||
describe('SKILL.md size budget regression (gate, free)', () => {
|
||||
test('parity-baseline-v1.80.0.0.json exists', () => {
|
||||
test('parity-baseline-v1.81.0.0.json exists', () => {
|
||||
expect(fs.existsSync(BASELINE_PATH)).toBe(true);
|
||||
});
|
||||
|
||||
test('no skill exceeds v1.80.0.0 baseline size × ratio', () => {
|
||||
test('no skill exceeds v1.81.0.0 baseline size × ratio', () => {
|
||||
const baseline: ParityBaseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
||||
const current = captureBaseline({ repoRoot: REPO_ROOT });
|
||||
|
||||
|
||||
@@ -332,6 +332,20 @@ describe('gstack-telemetry-log', () => {
|
||||
});
|
||||
|
||||
describe('.pending marker', () => {
|
||||
test('--no-sweep (one-shot setup events) leaves other sessions\' in-flight markers untouched', () => {
|
||||
setConfig('telemetry', 'anonymous');
|
||||
const analyticsDir = path.join(tmpDir, 'analytics');
|
||||
fs.mkdirSync(analyticsDir, { recursive: true });
|
||||
const marker = path.join(analyticsDir, '.pending-live-456');
|
||||
fs.writeFileSync(marker, '{"skill":"ship","ts":"2026-09-04T00:00:00Z","session_id":"live-456","gstack_version":"1.79.0.0"}');
|
||||
run(`${BIN}/gstack-telemetry-log --event-type onboarding --skill _setup_playwright --outcome chromium-install --no-sweep`);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
const events = parseJsonl();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].event_type).toBe('onboarding');
|
||||
expect(events[0].outcome).toBe('chromium-install');
|
||||
});
|
||||
|
||||
test('finalizes stale .pending from another session as outcome:unknown', () => {
|
||||
setConfig('telemetry', 'anonymous');
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -86,7 +87,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
|
||||
extractFn(src, '_swap_in_render'),
|
||||
`_swap_in_render "${live}" "${fresh}"`,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
// Live dir now serves the fresh render at the SAME path (links into
|
||||
// it stay valid), tmp and .old are gone.
|
||||
@@ -126,7 +127,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
|
||||
' echo "render failed — previous render left in place" >&2',
|
||||
'fi',
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n');
|
||||
// The installed symlink still resolves — the skill set did not vanish.
|
||||
@@ -175,10 +176,23 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
|
||||
extractFn(SETUP_SRC, '_link_or_copy'),
|
||||
extractFn(SETUP_SRC, '_print_windows_copy_note_once'),
|
||||
extractFn(SETUP_SRC, '_link_skill_runtime_assets'),
|
||||
extractFn(SETUP_SRC, '_gstack_link_target_abs'),
|
||||
extractFn(SETUP_SRC, '_gstack_target_is_ours'),
|
||||
extractFn(SETUP_SRC, '_gstack_generated_header'),
|
||||
extractFn(SETUP_SRC, '_claude_entry_owned_strongly'),
|
||||
extractFn(SETUP_SRC, '_claude_entry_is_ours'),
|
||||
extractFn(SETUP_SRC, '_write_owned_marker'),
|
||||
extractFn(SETUP_SRC, '_backup_skill_md'),
|
||||
extractFn(SETUP_SRC, '_cleanup_weak_dir'),
|
||||
extractFn(SETUP_SRC, '_gstack_dir_only_links'),
|
||||
extractFn(SETUP_SRC, '_cleanup_linked_dir'),
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
|
||||
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${src}" "${skills}"`,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha');
|
||||
|
||||
Reference in New Issue
Block a user