mirror of https://github.com/garrytan/gstack.git
Compare commits
4 Commits
e40ff49a5e
...
8e588a41a3
| Author | SHA1 | Date |
|---|---|---|
|
|
8e588a41a3 | |
|
|
c43c850cae | |
|
|
3bef43bc5a | |
|
|
5eb7f7e2f3 |
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -1,5 +1,93 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.55.1.0] - 2026-06-02
|
||||||
|
|
||||||
|
## **Telemetry now tells you exactly what it records and where it stays. The project-slug helper hands the shell a safe identifier on every path.**
|
||||||
|
|
||||||
|
The telemetry opt-in screen now states the truth without asterisks: it shares skill name, duration, crashes, and a stable device ID, with no code and no file paths, and your repo name is recorded locally only and stripped before any upload. Under the hood, the helper that every skill uses to find your project (`gstack-slug`) now filters its output to `[a-zA-Z0-9._-]` on every path, including the cached one, so the value that gets handed to the shell is always a plain identifier. Two regression tests lock both behaviors so they can't quietly drift back.
|
||||||
|
|
||||||
|
### The guarantees that matter
|
||||||
|
|
||||||
|
These are enforced by tests in this release, not promises (`bun test test/telemetry-repo-strip.test.ts test/gstack-slug-sanitize.test.ts`):
|
||||||
|
|
||||||
|
| Guarantee | Pinned by |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Your repo name never leaves the machine (stripped before upload) | `telemetry-repo-strip.test.ts` — floor + producer-coverage + runs the real strip over a sample event |
|
||||||
|
| A tampered slug cache can't put shell characters into the helper's output | `gstack-slug-sanitize.test.ts` — fails if the sanitization is removed |
|
||||||
|
| The consent copy matches what the code actually does | `generate-telemetry-prompt.ts` (regenerated into every skill) |
|
||||||
|
|
||||||
|
The repo-identity test covers all three producer fields (`repo`, `_repo_slug`, `_branch`), so adding a new field that forgets to get stripped fails CI rather than shipping silently.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
Your telemetry choice screen now describes what actually happens, so you can opt in (or not) on accurate information. If you share a machine or have ever worried about a tampered `~/.gstack` cache, the slug helper now refuses to pass anything but a safe identifier to the shell. Nothing to do — both land automatically on upgrade.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- Telemetry consent copy is now accurate: "No code or file paths. Your repo name is recorded locally only and stripped before any upload" (was "No code, file paths, or repo names").
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- `gstack-slug` sanitizes its output to `[a-zA-Z0-9._-]` on every path, including values read from its on-disk cache, so `eval "$(gstack-slug)"` always receives a plain identifier. A tampered cache file is also healed on the next write.
|
||||||
|
- The telemetry preamble sanitizes the repo basename before building its JSON line, so an unusual repo directory name can't malform the local analytics record.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- `test/telemetry-repo-strip.test.ts` — enforces that no repo/branch identity field reaches the upload batch (floor + producer-coverage + real-strip behavior).
|
||||||
|
- `test/gstack-slug-sanitize.test.ts` — regression test proving a poisoned slug cache cannot inject shell metacharacters.
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
- The consent copy and repo-basename handling live in `scripts/resolvers/preamble/`; all `SKILL.md` files and the ship goldens were regenerated from those resolvers.
|
||||||
|
|
||||||
|
## [1.55.0.0] - 2026-05-30
|
||||||
|
|
||||||
|
## **`/sync-gbrain` can no longer be the trigger that lets gbrain delete your repo. The headed browser stops crash-looping, and gbrain installs the current release instead of a pin 23 versions stale.**
|
||||||
|
|
||||||
|
gbrain can rm-rf a working tree when its autopilot daemon reclones mid-cycle. `/sync-gbrain` used to call gbrain's `sources remove` and `sync --strategy code` as if they were safe, so it could be the thing that set that race off. Now every destructive gbrain call sits behind feature-detected guards: the orchestrator refuses to run while autopilot is active, refuses to remove a user-managed source it can't storage-protect (it fails closed), canonicalizes paths with realpath so a symlink can't smuggle a delete outside gbrain's own clones, and requires an explicit `--allow-reclone` before a URL-managed source's code walk. Shipped in the same wave: the headed browser's self-inflicted crash-loop is gone, big-brain memory ingests stop getting killed at a fixed 30 minutes, and the gbrain installer moves off its frozen v0.18.2 pin onto the latest release behind a version floor and a `doctor` self-test.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
From the shipped diff and its regression suites (`bun test test/gbrain-*.test.ts browse/test/restart-env.test.ts test/memory-ingest-timeout.test.ts`):
|
||||||
|
|
||||||
|
| Metric | Before | After | Δ |
|
||||||
|
|--------|--------|-------|---|
|
||||||
|
| Destructive gbrain ops behind guards | 0 | 4 | +4 |
|
||||||
|
| gbrain / brain-sync spawns that work on Windows | 0/8 | 8/8 | +8 |
|
||||||
|
| gbrain version installed | v0.18.2 (pinned, ~23 behind) | latest + min-version floor + doctor gate | — |
|
||||||
|
| Memory-ingest timeout | hardcoded 30 min | configurable, checkpoint preserved on timeout | — |
|
||||||
|
| Generated SKILL.md that parse under strict YAML | partial (colons broke Codex) | all (quoted) | — |
|
||||||
|
|
||||||
|
The guard that matters most: a `sources remove` on a source whose files live outside `~/.gbrain/clones/` and can't be storage-protected now refuses instead of proceeding. The path that ate a repo no longer runs unattended.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
If you use `/sync-gbrain`, you are protected from the data-loss race even before gbrain ships its own root fix. "Don't run `/sync-gbrain` while `gbrain autopilot` is active" is now enforced, not just advised, and nothing gets deleted that can't be proven safe. Headed-browser QA against beacon-heavy pages (analytics, live extensions) no longer crash-loops, leaks Chromium, or silently drops to an invisible headless window. New gbrain installs track the current release. Codex and OpenAI can load every gstack skill again.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- `/sync-gbrain` destructive-op guards (`lib/gbrain-guards.ts`): multi-signal autopilot detection, fail-closed `sources remove`, realpath `remote_url` pre-flight audit, and a `--allow-reclone` gate before URL-managed code walks.
|
||||||
|
- Install-time gbrain gate (`bin/gstack-gbrain-install`): a minimum-version floor and a `gbrain doctor --fast` self-test, both hard-fail with remediation.
|
||||||
|
- `GSTACK_INGEST_TIMEOUT_MS` to configure the memory-ingest timeout; on timeout the gbrain checkpoint is preserved so the next run resumes.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- gbrain installs at the latest default-branch HEAD by default; pin a commit with `gstack-gbrain-install --pinned-commit <sha>` for reproducibility.
|
||||||
|
- Generated SKILL.md descriptions with interior colons are now quoted, so strict YAML loaders (Codex/OpenAI) parse them.
|
||||||
|
- `/sync-gbrain` guidance: do not run during autopilot; prefer `gbrain sources add --path` over URL-managed sources.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- `/sync-gbrain` no longer races gbrain's autopilot into a destructive reclone or remove (#1734). Report by @mvanhorn.
|
||||||
|
- `gstack-jsonl-merge` resolves equal-timestamp entries deterministically across machines, so append-only logs converge instead of re-conflicting forever (#1769). Contributed by @jbetala7.
|
||||||
|
- Generated SKILL.md frontmatter parses under strict YAML loaders (#1778). Reported by @GilbertzzzZZ, @genisis0x, @cathrynlavery, and @sator-imaging.
|
||||||
|
- The headed browser daemon no longer crash-loops under load, leaks Chromium processes, or silently downgrades a headed session to headless (#1781).
|
||||||
|
- `/sync-gbrain --full` memory ingests on large brains are no longer killed at a fixed 30-minute timeout (#1611).
|
||||||
|
- The gbrain CLI and `gstack-brain-sync` spawn correctly on Windows (#1731).
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
- `lib/gbrain-guards.ts` with hermetic tests for every guard branch (autopilot signals, fail-closed remove, reclone gate, realpath containment).
|
||||||
|
- `parseSourcesList` centralizes `gbrain sources list --json` shape handling across all readers (#1576, whose crash was already fixed in v1.42.0.0 — this removes the last divergent reader).
|
||||||
|
- Static-grep tripwire (`test/gbrain-spawn-windows-shell.test.ts`) fails CI if a gbrain spawn drops the Windows shell flag.
|
||||||
|
- gbrain-side requirements for the root fixes (ungated reclone, `--keep-storage`, a cooperative remove-lease, a capability command, true ingest-resume, integration CI) are tracked for the gbrain repo.
|
||||||
|
|
||||||
## [1.54.0.0] - 2026-05-30
|
## [1.54.0.0] - 2026-05-30
|
||||||
|
|
||||||
## **The heaviest skill stopped taxing every session. /ship's always-loaded cost dropped 59%, and its prose now loads only when a step needs it.**
|
## **The heaviest skill stopped taxing every session. /ship's always-loaded cost dropped 59%, and its prose now loads only when a step needs it.**
|
||||||
|
|
|
||||||
|
|
@ -938,4 +938,10 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
|
||||||
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
||||||
machine — gbrain's daemon handles incremental refresh on a schedule.
|
machine — gbrain's daemon handles incremental refresh on a schedule.
|
||||||
|
|
||||||
|
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
|
||||||
|
orchestrator refuses destructive source ops when it detects a running autopilot
|
||||||
|
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
|
||||||
|
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
|
||||||
|
sync code walk for them requires an explicit `--allow-reclone` opt-in.
|
||||||
|
|
||||||
<!-- gstack-gbrain-search-guidance:end -->
|
<!-- gstack-gbrain-search-guidance:end -->
|
||||||
|
|
|
||||||
4
SKILL.md
4
SKILL.md
|
|
@ -60,7 +60,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"gstack","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"gstack","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -170,7 +170,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ The skill runs three stages — code, memory, brain-sync — independently. A fa
|
||||||
|
|
||||||
1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs.
|
1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs.
|
||||||
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`.
|
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`.
|
||||||
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline.
|
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min–24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over.
|
||||||
4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured.
|
4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured.
|
||||||
5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed.
|
5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed.
|
||||||
|
|
||||||
|
|
@ -379,7 +379,7 @@ Another gstack session in a sibling Conductor workspace may be holding a lock on
|
||||||
## Related skills + next steps
|
## Related skills + next steps
|
||||||
|
|
||||||
- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice.
|
- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice.
|
||||||
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. To bump gbrain, update `PINNED_COMMIT` in `bin/gstack-gbrain-install` and re-run `/setup-gbrain`.
|
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. gbrain installs at the latest HEAD by default; to refresh it, `git pull` in your gbrain clone (default `~/gbrain`) and re-run `/setup-gbrain`. Pin a specific commit with `gstack-gbrain-install --pinned-commit <sha>` if you need reproducibility. Installs below the minimum tested version are refused.
|
||||||
- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history.
|
- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history.
|
||||||
|
|
||||||
Run `/setup-gbrain` and see what sticks.
|
Run `/setup-gbrain` and see what sticks.
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"autoplan","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"autoplan","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -179,7 +179,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"benchmark-models","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"benchmark-models","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"benchmark","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"benchmark","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@
|
||||||
# - git
|
# - git
|
||||||
# - network reachability to https://github.com
|
# - network reachability to https://github.com
|
||||||
#
|
#
|
||||||
# The pinned commit is declared here rather than resolved dynamically so
|
# gbrain installs at the latest default-branch HEAD by default — the hard pin
|
||||||
# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack
|
# was removed in #1744 (it had drifted ~23 versions behind). Pass
|
||||||
# verifies compatibility with a new gbrain release.
|
# --pinned-commit <sha> to install a specific commit for reproducibility. A
|
||||||
|
# minimum-version floor (MIN_GBRAIN_VERSION) hard-fails the install when the
|
||||||
|
# resulting gbrain is too old for gstack's sync integration, and a fast
|
||||||
|
# `gbrain doctor` self-test hard-fails a broken install when gbrain is already
|
||||||
|
# configured. This keeps the version gate that the pin used to provide without
|
||||||
|
# freezing users 23 releases behind.
|
||||||
#
|
#
|
||||||
# Env:
|
# Env:
|
||||||
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
|
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
|
||||||
|
|
@ -33,8 +38,14 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- defaults ---
|
# --- defaults ---
|
||||||
PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2
|
# No version pin by default — install the latest default-branch HEAD (#1744).
|
||||||
PINNED_TAG="v0.18.2"
|
# --pinned-commit <sha> overrides for reproducibility.
|
||||||
|
PINNED_COMMIT=""
|
||||||
|
PINNED_TAG=""
|
||||||
|
# Minimum gbrain version gstack's integration is known to work with. The
|
||||||
|
# `sources list --json` wrapped-object shape + federated sources landed by 0.20;
|
||||||
|
# older predates the surface gstack drives. Hard-fail below this floor (#1744).
|
||||||
|
MIN_GBRAIN_VERSION="0.20.0"
|
||||||
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
|
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
|
||||||
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
|
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
|
||||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||||
|
|
@ -113,7 +124,7 @@ elif [ -n "$DETECTED_CLONE" ]; then
|
||||||
else
|
else
|
||||||
# Fresh clone path.
|
# Fresh clone path.
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT → $INSTALL_DIR"
|
log "DRY RUN: would clone $GBRAIN_REPO_URL ${PINNED_COMMIT:+@ $PINNED_COMMIT }→ $INSTALL_DIR (latest HEAD unless --pinned-commit)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -d "$INSTALL_DIR" ]; then
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
|
|
@ -121,8 +132,12 @@ else
|
||||||
fi
|
fi
|
||||||
log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR"
|
log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR"
|
||||||
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
|
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
|
||||||
|
if [ -n "$PINNED_COMMIT" ]; then
|
||||||
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
|
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
|
||||||
log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
|
log "checked out pinned commit $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
|
||||||
|
else
|
||||||
|
log "installed latest gbrain (default-branch HEAD)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
|
|
@ -195,6 +210,44 @@ fi
|
||||||
|
|
||||||
log "installed gbrain $actual_version from $INSTALL_DIR"
|
log "installed gbrain $actual_version from $INSTALL_DIR"
|
||||||
|
|
||||||
|
# --- minimum-version floor (#1744) ---
|
||||||
|
# Unpinning means new installs track gbrain HEAD. Hard-fail if the resulting
|
||||||
|
# version is below the floor gstack's sync integration needs — same exit-3 posture
|
||||||
|
# as the PATH-shadow / version-mismatch failures above. A warning here is exactly
|
||||||
|
# how the data-loss class slipped through, so this gate fails closed.
|
||||||
|
version_lt() {
|
||||||
|
# 0 (true) when $1 < $2 by version sort; equal versions are NOT less-than.
|
||||||
|
[ "$1" = "$2" ] && return 1
|
||||||
|
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
|
||||||
|
}
|
||||||
|
if version_lt "$actual_norm" "$MIN_GBRAIN_VERSION"; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "gstack-gbrain-install: gbrain $actual_version is below the minimum gstack-tested version ($MIN_GBRAIN_VERSION)." >&2
|
||||||
|
echo " gstack's sync integration needs the v0.20+ source/list surface." >&2
|
||||||
|
echo " Fix: update the gbrain clone at $INSTALL_DIR to a newer release (git pull), then" >&2
|
||||||
|
echo " re-run /setup-gbrain. Or pass --pinned-commit <sha> to install a specific newer commit." >&2
|
||||||
|
echo "" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- functional self-test when gbrain is already configured (#1744) ---
|
||||||
|
# When a brain config exists (re-install / detected clone), run a fast doctor as
|
||||||
|
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
|
||||||
|
# Pre-init installs skip this (config not written yet); the full
|
||||||
|
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
|
||||||
|
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
|
||||||
|
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
|
||||||
|
if ! gbrain doctor --fast >/dev/null 2>&1; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&2
|
||||||
|
echo " Refusing to leave a broken gbrain in place. Run 'gbrain doctor' to see what's wrong," >&2
|
||||||
|
echo " fix it, then re-run /setup-gbrain." >&2
|
||||||
|
echo "" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
log "gbrain doctor --fast passed"
|
||||||
|
fi
|
||||||
|
|
||||||
# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts
|
# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts
|
||||||
# may skip artifacts gbrain needs at runtime, especially on Windows
|
# may skip artifacts gbrain needs at runtime, especially on Windows
|
||||||
# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above
|
# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ import { createHash } from "crypto";
|
||||||
|
|
||||||
import "../lib/conductor-env-shim";
|
import "../lib/conductor-env-shim";
|
||||||
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||||
import { ensureSourceRegistered, sourcePageCount } from "../lib/gbrain-sources";
|
import { ensureSourceRegistered, sourcePageCount, parseSourcesList } from "../lib/gbrain-sources";
|
||||||
|
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
|
||||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson } from "../lib/gbrain-exec";
|
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -52,6 +53,8 @@ interface CliArgs {
|
||||||
noMemory: boolean;
|
noMemory: boolean;
|
||||||
noBrainSync: boolean;
|
noBrainSync: boolean;
|
||||||
codeOnly: boolean;
|
codeOnly: boolean;
|
||||||
|
/** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */
|
||||||
|
allowReclone: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodeStageDetail {
|
interface CodeStageDetail {
|
||||||
|
|
@ -59,7 +62,7 @@ interface CodeStageDetail {
|
||||||
source_path?: string;
|
source_path?: string;
|
||||||
page_count?: number | null;
|
page_count?: number | null;
|
||||||
last_imported?: string;
|
last_imported?: string;
|
||||||
status?: "ok" | "skipped" | "failed";
|
status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StageResult {
|
interface StageResult {
|
||||||
|
|
@ -205,6 +208,8 @@ Options:
|
||||||
--no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts).
|
--no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts).
|
||||||
--no-brain-sync Skip the gstack-brain-sync git pipeline stage.
|
--no-brain-sync Skip the gstack-brain-sync git pipeline stage.
|
||||||
--code-only Only run the code-import stage (alias for --no-memory --no-brain-sync).
|
--code-only Only run the code-import stage (alias for --no-memory --no-brain-sync).
|
||||||
|
--allow-reclone Permit the code walk for URL-managed sources (remote_url set)
|
||||||
|
even though gbrain may auto-reclone the working tree (#1734).
|
||||||
--help This text.
|
--help This text.
|
||||||
|
|
||||||
Stages run in order: code → memory ingest → curated git push.
|
Stages run in order: code → memory ingest → curated git push.
|
||||||
|
|
@ -220,6 +225,7 @@ function parseArgs(): CliArgs {
|
||||||
let noMemory = false;
|
let noMemory = false;
|
||||||
let noBrainSync = false;
|
let noBrainSync = false;
|
||||||
let codeOnly = false;
|
let codeOnly = false;
|
||||||
|
let allowReclone = false;
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const a = args[i];
|
const a = args[i];
|
||||||
|
|
@ -231,6 +237,7 @@ function parseArgs(): CliArgs {
|
||||||
case "--no-code": noCode = true; break;
|
case "--no-code": noCode = true; break;
|
||||||
case "--no-memory": noMemory = true; break;
|
case "--no-memory": noMemory = true; break;
|
||||||
case "--no-brain-sync": noBrainSync = true; break;
|
case "--no-brain-sync": noBrainSync = true; break;
|
||||||
|
case "--allow-reclone": allowReclone = true; break;
|
||||||
case "--code-only":
|
case "--code-only":
|
||||||
codeOnly = true;
|
codeOnly = true;
|
||||||
noMemory = true;
|
noMemory = true;
|
||||||
|
|
@ -247,7 +254,7 @@ function parseArgs(): CliArgs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly };
|
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, allowReclone };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -407,10 +414,7 @@ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): stri
|
||||||
{ baseEnv: env },
|
{ baseEnv: env },
|
||||||
);
|
);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const list: Array<{ id?: string; local_path?: string }> = Array.isArray(raw)
|
const found = parseSourcesList(raw).find((s) => s.id === sourceId);
|
||||||
? (raw as Array<{ id?: string; local_path?: string }>)
|
|
||||||
: ((raw as { sources?: Array<{ id?: string; local_path?: string }> }).sources ?? []);
|
|
||||||
const found = list.find((s) => s.id === sourceId);
|
|
||||||
return found?.local_path ?? null;
|
return found?.local_path ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,20 +473,50 @@ export function planHostnameFoldMigration(
|
||||||
return { kind: "pending-cleanup", oldId: legacyPathHashId };
|
return { kind: "pending-cleanup", oldId: legacyPathHashId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuardedRemoveResult {
|
||||||
|
removed: boolean;
|
||||||
|
/** True when a guard refused the remove (autopilot active or unsafe source). */
|
||||||
|
skipped: boolean;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1734: run `gbrain sources remove <id> --confirm-destructive` only behind the
|
||||||
|
* data-loss guards. Checked immediately before the destructive op (E8: as late
|
||||||
|
* as possible) so the autopilot window is as small as we can make it without a
|
||||||
|
* gbrain-side lease. Refuses when autopilot is active or when the source is
|
||||||
|
* user-managed and gbrain can't keep its storage. Pure side-effect helper; the
|
||||||
|
* caller decides whether a skip is fatal (it never is today — removes are
|
||||||
|
* best-effort cleanup).
|
||||||
|
*/
|
||||||
|
export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult {
|
||||||
|
const ap = detectAutopilot(env);
|
||||||
|
if (ap.active) {
|
||||||
|
return {
|
||||||
|
removed: false,
|
||||||
|
skipped: true,
|
||||||
|
reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` +
|
||||||
|
`Stop autopilot, then re-run /sync-gbrain.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const decision = decideSourceRemove(sourceId, env);
|
||||||
|
if (!decision.allow) {
|
||||||
|
return { removed: false, skipped: true, reason: decision.reason };
|
||||||
|
}
|
||||||
|
const r = spawnGbrain(
|
||||||
|
["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs],
|
||||||
|
{ baseEnv: env },
|
||||||
|
);
|
||||||
|
return { removed: r.status === 0, skipped: false, reason: decision.reason };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an orphaned source. Called only after new-source sync verifies pages
|
* Remove an orphaned source. Called only after new-source sync verifies pages
|
||||||
* exist, so the old source is provably redundant before deletion.
|
* exist, so the old source is provably redundant before deletion. Routed through
|
||||||
*
|
* safeSourcesRemove for the #1734 guards.
|
||||||
* Flag note: existing call sites used `--confirm-destructive` here and
|
|
||||||
* `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither
|
|
||||||
* deterministically (the subcommand surface help is generic). We pass
|
|
||||||
* `--confirm-destructive` to match the existing call site convention; the
|
|
||||||
* flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve
|
|
||||||
* the inconsistency across the codebase.
|
|
||||||
*/
|
*/
|
||||||
export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean {
|
export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean {
|
||||||
const r = spawnGbrain(["sources", "remove", oldId, "--confirm-destructive"], { baseEnv: env });
|
return safeSourcesRemove(oldId, env).removed;
|
||||||
return r.status === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -661,13 +695,12 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||||
const legacyId = deriveLegacyCodeSourceId(root);
|
const legacyId = deriveLegacyCodeSourceId(root);
|
||||||
let legacyRemoved = false;
|
let legacyRemoved = false;
|
||||||
if (legacyId !== sourceId) {
|
if (legacyId !== sourceId) {
|
||||||
const rm = spawnGbrain(["sources", "remove", legacyId, "--confirm-destructive"], {
|
// #1734: route through the data-loss guards (autopilot + source-safety).
|
||||||
timeout: 30_000,
|
const rm = safeSourcesRemove(legacyId, gbrainEnv);
|
||||||
baseEnv: gbrainEnv,
|
if (rm.skipped && !args.quiet) {
|
||||||
});
|
console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`);
|
||||||
// Treat absent-source as success (clean state). gbrain emits "not found" on
|
}
|
||||||
// missing id; treat any non-zero exit without "not found" as a soft fail.
|
if (rm.removed) legacyRemoved = true;
|
||||||
if (rm.status === 0) legacyRemoved = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 0b: Hostname-fold migration (#1414).
|
// Step 0b: Hostname-fold migration (#1414).
|
||||||
|
|
@ -720,6 +753,29 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||||
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
|
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
|
||||||
"GSTACK_SYNC_CODE_TIMEOUT_MS",
|
"GSTACK_SYNC_CODE_TIMEOUT_MS",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #1734 guards, checked immediately before the destructive walk (E8):
|
||||||
|
// - autopilot active → refuse (the race that wiped a working tree).
|
||||||
|
// - URL-managed source → the walk can auto-reclone (rm-rf); require
|
||||||
|
// --allow-reclone. Both surface a visible reason and fail the stage so the
|
||||||
|
// verdict shows ERR rather than silently skipping protection.
|
||||||
|
const apBeforeWalk = detectAutopilot(gbrainEnv);
|
||||||
|
if (apBeforeWalk.active) {
|
||||||
|
return {
|
||||||
|
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
|
||||||
|
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
|
||||||
|
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
|
||||||
|
if (!reclone.allow) {
|
||||||
|
return {
|
||||||
|
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
|
||||||
|
summary: `refused: ${reclone.reason}`,
|
||||||
|
detail: { source_id: sourceId, source_path: root, status: "refused-reclone" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
|
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
|
||||||
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
||||||
timeout: codeTimeoutMs,
|
timeout: codeTimeoutMs,
|
||||||
|
|
@ -961,13 +1017,17 @@ function runBrainSyncPush(args: CliArgs): StageResult {
|
||||||
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
|
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it
|
||||||
|
// without a shell, which surfaced as "brain-sync exited undefined".
|
||||||
spawnSync(brainSyncPath, ["--discover-new"], {
|
spawnSync(brainSyncPath, ["--discover-new"], {
|
||||||
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
||||||
timeout: 60 * 1000,
|
timeout: 60 * 1000,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
});
|
});
|
||||||
const result = spawnSync(brainSyncPath, ["--once"], {
|
const result = spawnSync(brainSyncPath, ["--once"], {
|
||||||
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
|
||||||
timeout: 60 * 1000,
|
timeout: 60 * 1000,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -53,18 +53,25 @@ for path in paths:
|
||||||
continue
|
continue
|
||||||
if line in seen:
|
if line in seen:
|
||||||
continue
|
continue
|
||||||
# Prefer ISO ts field for sort; fall back to SHA-256.
|
# Prefer ISO ts field for sort; fall back to SHA-256. The line
|
||||||
|
# content is the final tiebreaker so the order is total: two
|
||||||
|
# entries sharing a ts must resolve identically regardless of
|
||||||
|
# which side they arrive on. Without it, equal-ts entries fall
|
||||||
|
# back to insertion order (base, ours, theirs), and since ours
|
||||||
|
# and theirs are swapped depending on which machine runs the
|
||||||
|
# merge, the two sides produce divergent files that never
|
||||||
|
# converge.
|
||||||
sort_key = None
|
sort_key = None
|
||||||
try:
|
try:
|
||||||
obj = json.loads(line)
|
obj = json.loads(line)
|
||||||
ts = obj.get('ts') or obj.get('timestamp')
|
ts = obj.get('ts') or obj.get('timestamp')
|
||||||
if isinstance(ts, str):
|
if isinstance(ts, str):
|
||||||
sort_key = (0, ts)
|
sort_key = (0, ts, line)
|
||||||
except (json.JSONDecodeError, ValueError, TypeError):
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if sort_key is None:
|
if sort_key is None:
|
||||||
h = hashlib.sha256(line.encode('utf-8')).hexdigest()
|
h = hashlib.sha256(line.encode('utf-8')).hexdigest()
|
||||||
sort_key = (1, h)
|
sort_key = (1, h, line)
|
||||||
seen[line] = sort_key
|
seen[line] = sort_key
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Absent base / absent ours / absent theirs are all valid.
|
# Absent base / absent ours / absent theirs are all valid.
|
||||||
|
|
|
||||||
|
|
@ -1349,10 +1349,32 @@ function installSignalForwarder(): void {
|
||||||
* that kill the child on parent SIGTERM/SIGINT. Returns the same shape as
|
* that kill the child on parent SIGTERM/SIGINT. Returns the same shape as
|
||||||
* spawnSync's result so the caller doesn't care which mode was used.
|
* spawnSync's result so the caller doesn't care which mode was used.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* #1611: the `gbrain import` is the long pole on big brains. Its timeout is
|
||||||
|
* configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, 1min–24h) so large
|
||||||
|
* memory corpora aren't SIGTERM'd mid-import. On timeout we SIGTERM the child,
|
||||||
|
* which preserves gbrain's import-checkpoint.json (see installSignalForwarder)
|
||||||
|
* so the next run resumes instead of restarting from scratch.
|
||||||
|
*/
|
||||||
|
const DEFAULT_IMPORT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
export function resolveImportTimeoutMs(
|
||||||
|
raw: string | undefined = process.env.GSTACK_INGEST_TIMEOUT_MS,
|
||||||
|
): number {
|
||||||
|
if (raw === undefined || raw === "") return DEFAULT_IMPORT_TIMEOUT_MS;
|
||||||
|
const n = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(n) || Number.isNaN(n) || n < 60_000 || n > 86_400_000) {
|
||||||
|
console.error(
|
||||||
|
`[memory-ingest] GSTACK_INGEST_TIMEOUT_MS="${raw}" invalid (need 60000–86400000ms); using ${DEFAULT_IMPORT_TIMEOUT_MS}ms`,
|
||||||
|
);
|
||||||
|
return DEFAULT_IMPORT_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
function runGbrainImport(
|
function runGbrainImport(
|
||||||
stagingDir: string,
|
stagingDir: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
|
||||||
installSignalForwarder();
|
installSignalForwarder();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Seed DATABASE_URL from gbrain's own config so this stage works
|
// Seed DATABASE_URL from gbrain's own config so this stage works
|
||||||
|
|
@ -1385,6 +1407,7 @@ function runGbrainImport(
|
||||||
status: timedOut ? null : status,
|
status: timedOut ? null : status,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
|
timedOut,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
|
|
@ -1394,6 +1417,7 @@ function runGbrainImport(
|
||||||
status: null,
|
status: null,
|
||||||
stdout,
|
stdout,
|
||||||
stderr: stderr + `\n[spawn-error] ${(err as Error).message}`,
|
stderr: stderr + `\n[spawn-error] ${(err as Error).message}`,
|
||||||
|
timedOut,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1608,13 +1632,33 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
|
||||||
// spawn, parent termination orphans the gbrain process (observed
|
// spawn, parent termination orphans the gbrain process (observed
|
||||||
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
|
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
|
||||||
// after the orchestrator timed out).
|
// after the orchestrator timed out).
|
||||||
const importResult = await runGbrainImport(stagingDir, 30 * 60 * 1000);
|
const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
|
||||||
|
|
||||||
const stdout = importResult.stdout || "";
|
const stdout = importResult.stdout || "";
|
||||||
const stderr = importResult.stderr || "";
|
const stderr = importResult.stderr || "";
|
||||||
const importJson = parseImportJson(stdout);
|
const importJson = parseImportJson(stdout);
|
||||||
|
|
||||||
if (importResult.status !== 0) {
|
if (importResult.status !== 0) {
|
||||||
|
// #1611: on timeout, gbrain's import-checkpoint.json is preserved (the
|
||||||
|
// SIGTERM forwarder keeps the staging dir), so the next /sync-gbrain
|
||||||
|
// resumes rather than restarting. Tell the user instead of looking failed.
|
||||||
|
if (importResult.timedOut) {
|
||||||
|
const mins = Math.round(resolveImportTimeoutMs() / 60000);
|
||||||
|
const msg =
|
||||||
|
`gbrain import timed out after ${mins}min; checkpoint preserved — re-run ` +
|
||||||
|
`/sync-gbrain to resume (raise GSTACK_INGEST_TIMEOUT_MS for big brains)`;
|
||||||
|
console.error(`[memory-ingest] ${msg}`);
|
||||||
|
return {
|
||||||
|
written: 0,
|
||||||
|
skipped_secret: prep.skippedSecret,
|
||||||
|
skipped_dedup: prep.skippedDedup,
|
||||||
|
skipped_unattributed: prep.skippedUnattributed,
|
||||||
|
failed,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
partial_pages: prep.partialPages,
|
||||||
|
system_error: msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
const tail = (stderr.trim().split("\n").pop() || "").slice(0, 300);
|
const tail = (stderr.trim().split("\n").pop() || "").slice(0, 300);
|
||||||
const msg = `gbrain import exited ${importResult.status}: ${tail}`;
|
const msg = `gbrain import exited ${importResult.status}: ${tail}`;
|
||||||
console.error(`[memory-ingest] ERR: ${msg}`);
|
console.error(`[memory-ingest] ERR: ${msg}`);
|
||||||
|
|
@ -1810,7 +1854,12 @@ async function main(): Promise<void> {
|
||||||
if (result.system_error) process.exit(1);
|
if (result.system_error) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
// Guard so the module is import-safe for unit tests (e.g. resolveImportTimeoutMs).
|
||||||
|
// The orchestrator runs it as `bun gstack-memory-ingest.ts ...`, where
|
||||||
|
// import.meta.main is true, so the CLI path is unaffected.
|
||||||
|
if (import.meta.main) {
|
||||||
|
main().catch((err) => {
|
||||||
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`);
|
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ fi
|
||||||
# 3. Fallback to basename only when there's truly no git remote configured
|
# 3. Fallback to basename only when there's truly no git remote configured
|
||||||
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
SLUG="${SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
|
||||||
|
|
||||||
|
# 3b. Re-sanitize unconditionally before the value is echoed into `eval`/`source`
|
||||||
|
# output. The compute (2) and fallback (3) paths already filter, but a value
|
||||||
|
# read straight from the cache file (1) does NOT — a poisoned
|
||||||
|
# ~/.gstack/slug-cache/<key> would otherwise inject shell into
|
||||||
|
# `eval "$(gstack-slug)"`. Filtering here honors the [a-zA-Z0-9._-] invariant
|
||||||
|
# promised in the header on every path, and heals a poisoned cache on write (4).
|
||||||
|
SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
|
||||||
|
|
||||||
# 4. Cache the slug for future sessions (atomic write, fail silently)
|
# 4. Cache the slug for future sessions (atomic write, fail silently)
|
||||||
if [[ -n "$SLUG" ]]; then
|
if [[ -n "$SLUG" ]]; then
|
||||||
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
||||||
|
|
|
||||||
|
|
@ -196,9 +196,14 @@ function bumpPref(list: Preference[], value: string, opposite: Preference[], act
|
||||||
entry.rejected_count += 1;
|
entry.rejected_count += 1;
|
||||||
}
|
}
|
||||||
entry.last_seen = now;
|
entry.last_seen = now;
|
||||||
// Laplace-smoothed confidence
|
// Laplace-smoothed confidence in THIS bucket's signal: an approved entry's
|
||||||
|
// confidence reflects how strongly it's approved, a rejected entry's how
|
||||||
|
// strongly it's rejected. Using approved_count for both buckets pinned every
|
||||||
|
// rejected entry to 0 (rejected entries never gain approvals), which made the
|
||||||
|
// `show` ranking meaningless and the taste-drift warning unreachable.
|
||||||
const total = entry.approved_count + entry.rejected_count;
|
const total = entry.approved_count + entry.rejected_count;
|
||||||
entry.confidence = entry.approved_count / (total + 1);
|
const ownCount = action === 'approved' ? entry.approved_count : entry.rejected_count;
|
||||||
|
entry.confidence = ownCount / (total + 1);
|
||||||
// Flag conflict if the opposite bucket has a strong entry for this value
|
// Flag conflict if the opposite bucket has a strong entry for this value
|
||||||
const opp = opposite.find(p => p.value.toLowerCase() === value.toLowerCase());
|
const opp = opposite.find(p => p.value.toLowerCase() === value.toLowerCase());
|
||||||
if (opp && opp.approved_count + opp.rejected_count >= 3 && opp.confidence >= 0.6) {
|
if (opp && opp.approved_count + opp.rejected_count >= 3 && opp.confidence >= 0.6) {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -171,7 +171,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,86 @@ function cleanupLegacyState(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Chromium profile lock helpers (#1781) ─────────────────────
|
||||||
|
/** Profile dir used by headed/connect Chromium sessions. */
|
||||||
|
function chromiumProfileDir(): string {
|
||||||
|
return path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove Chromium SingletonLock/Socket/Cookie so a relaunch can acquire the
|
||||||
|
* profile. Safe to call when absent. */
|
||||||
|
function cleanChromiumProfileLocks(profileDir: string = chromiumProfileDir()): void {
|
||||||
|
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||||
|
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill an orphaned Chromium that still holds the profile's SingletonLock. The
|
||||||
|
* lock symlink target is "hostname-PID"; killing that PID tears down its
|
||||||
|
* renderer tree so the next launch starts clean. No-op when absent/stale. */
|
||||||
|
async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lockTarget = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); // "hostname-12345"
|
||||||
|
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
|
||||||
|
if (orphanPid && isProcessAlive(orphanPid)) {
|
||||||
|
safeKill(orphanPid, 'SIGTERM');
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
if (isProcessAlive(orphanPid)) {
|
||||||
|
safeKill(orphanPid, 'SIGKILL');
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounded /health probe. Returns true if the server answers within `attempts`
|
||||||
|
* tries spaced `backoffMs` apart — distinguishes a busy-but-alive daemon from a
|
||||||
|
* dead one (#1781) so a slow server isn't killed and restarted into a crash-loop. */
|
||||||
|
async function probeHealthWithBackoff(port: number, attempts = 3, backoffMs = 250): Promise<boolean> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
if (await isServerHealthy(port)) return true;
|
||||||
|
if (i < attempts - 1) await Bun.sleep(backoffMs);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the env for an auto-restart after a crash. headed/proxy/configHash are
|
||||||
|
* reapplied from THIS invocation OR the persisted server state, so a restart
|
||||||
|
* triggered by a plain command (goto/status, no --headed flag) never silently
|
||||||
|
* downgrades a headed session to headless (#1781). Pure + exported for tests.
|
||||||
|
*/
|
||||||
|
export function buildRestartEnv(
|
||||||
|
globalFlags: GlobalFlags | null | undefined,
|
||||||
|
oldState: ServerState | null,
|
||||||
|
): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
if (globalFlags?.proxyUrl) env.BROWSE_PROXY_URL = globalFlags.proxyUrl;
|
||||||
|
if (globalFlags?.headed || oldState?.mode === 'headed') env.BROWSE_HEADED = '1';
|
||||||
|
const configHash = globalFlags?.configHash || oldState?.configHash;
|
||||||
|
if (configHash) env.BROWSE_CONFIG_HASH = configHash;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** macOS only: pull the headed Chromium window to the user's current Space.
|
||||||
|
* "Google Chrome for Testing" frequently opens behind the active window or on
|
||||||
|
* another Space — the first thing users read as "I can't see the browser"
|
||||||
|
* (#1781). Best-effort, fire-and-forget, never throws. The app name is a fixed
|
||||||
|
* literal (no interpolation). */
|
||||||
|
function raiseHeadedWindowMacOS(): void {
|
||||||
|
if (process.platform !== 'darwin') return;
|
||||||
|
try {
|
||||||
|
nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
detached: true,
|
||||||
|
}).unref();
|
||||||
|
} catch {
|
||||||
|
// osascript missing or app not present — non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||||
ensureStateDir(config);
|
ensureStateDir(config);
|
||||||
|
|
@ -219,6 +299,13 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||||
safeUnlink(config.stateFile);
|
safeUnlink(config.stateFile);
|
||||||
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
|
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
|
||||||
|
|
||||||
|
// #1781: clear a stale Chromium profile lock (and kill the orphan still
|
||||||
|
// holding it) before launch, so an auto-restart after an abrupt kill isn't
|
||||||
|
// blocked by the previous Chromium's SingletonLock — the self-inflicted
|
||||||
|
// crash-loop. Previously only the manual connect preamble did this.
|
||||||
|
await killOrphanChromium();
|
||||||
|
cleanChromiumProfileLocks();
|
||||||
|
|
||||||
// Allow the caller to opt out of the parent-process watchdog by setting
|
// Allow the caller to opt out of the parent-process watchdog by setting
|
||||||
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
|
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
|
||||||
// shells, and short-lived Bash invocations that need the server to outlive
|
// shells, and short-lived Bash invocations that need the server to outlive
|
||||||
|
|
@ -486,26 +573,42 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
console.error('[browse] Command timed out after 30s');
|
// #1781: a 30s timeout on a heavy page usually means busy, not dead.
|
||||||
|
// Don't kill a live server (that's what triggered the crash-loop) — report
|
||||||
|
// and exit so the user can retry rather than losing their (headed) window.
|
||||||
|
const ts = readState();
|
||||||
|
const alive = ts?.pid ? isProcessAlive(ts.pid) : false;
|
||||||
|
console.error(alive
|
||||||
|
? '[browse] Command timed out after 30s (server still alive — busy, not restarting). Retry, or raise load.'
|
||||||
|
: '[browse] Command timed out after 30s');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// Connection error — server may have crashed
|
// Connection error — server may have crashed, OR may just be busy.
|
||||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
||||||
|
const oldState = readState();
|
||||||
|
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
||||||
|
// can briefly stop answering HTTP while still alive. Before declaring a
|
||||||
|
// crash, if the process is alive give /health a bounded chance to recover
|
||||||
|
// and just retry the command — never kill+restart a live-but-busy server.
|
||||||
|
if (oldState?.pid && isProcessAlive(oldState.pid) && await probeHealthWithBackoff(oldState.port)) {
|
||||||
|
if (retries >= 1) throw new Error('[browse] Server unresponsive after retry — aborting');
|
||||||
|
console.error('[browse] Server was briefly unresponsive (busy); retrying command...');
|
||||||
|
return sendCommand(oldState, command, args, retries + 1);
|
||||||
|
}
|
||||||
|
// Truly dead (or health never recovered) → restart.
|
||||||
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
||||||
console.error('[browse] Server connection lost. Restarting...');
|
console.error('[browse] Server connection lost. Restarting...');
|
||||||
// Kill the old server to avoid orphaned chromium processes
|
|
||||||
const oldState = readState();
|
|
||||||
if (oldState && oldState.pid) {
|
if (oldState && oldState.pid) {
|
||||||
await killServer(oldState.pid);
|
await killServer(oldState.pid);
|
||||||
}
|
}
|
||||||
// Reapply --proxy / --headed flags from this invocation when restarting
|
// startServer() now clears the Chromium SingletonLock + reaps the orphan,
|
||||||
// after a crash. Without this, a proxied daemon that dies mid-command
|
// so the relaunch isn't blocked by the dead Chromium's profile lock (#1781).
|
||||||
// would silently restart in default direct/headless mode and bypass
|
//
|
||||||
// the SOCKS bridge.
|
// Reapply --proxy / --headed when restarting. headed comes from THIS
|
||||||
const restartEnv: Record<string, string> = {};
|
// invocation OR the persisted server mode, so a restart triggered by a
|
||||||
if (_globalFlags?.proxyUrl) restartEnv.BROWSE_PROXY_URL = _globalFlags.proxyUrl;
|
// plain command (goto/status, no --headed) never silently downgrades a
|
||||||
if (_globalFlags?.headed) restartEnv.BROWSE_HEADED = '1';
|
// headed session to headless (#1781). Same for proxy/configHash.
|
||||||
if (_globalFlags?.configHash) restartEnv.BROWSE_CONFIG_HASH = _globalFlags.configHash;
|
const restartEnv = buildRestartEnv(_globalFlags, oldState);
|
||||||
const newState = await startServer(Object.keys(restartEnv).length ? restartEnv : undefined);
|
const newState = await startServer(Object.keys(restartEnv).length ? restartEnv : undefined);
|
||||||
return sendCommand(newState, command, args, retries + 1);
|
return sendCommand(newState, command, args, retries + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -966,30 +1069,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill orphaned Chromium processes that may still hold the profile lock.
|
// Kill an orphaned Chromium still holding the profile lock (the Bun server
|
||||||
// The server PID is the Bun process; Chromium is a child that can outlive it
|
// PID's Chromium child can outlive an abrupt kill/crash), then clear the
|
||||||
// if the server is killed abruptly (SIGKILL, crash, manual rm of state file).
|
// lock files so the launch is clean. Shared with the auto-restart path (#1781).
|
||||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
await killOrphanChromium();
|
||||||
try {
|
cleanChromiumProfileLocks();
|
||||||
const singletonLock = path.join(profileDir, 'SingletonLock');
|
|
||||||
const lockTarget = fs.readlinkSync(singletonLock); // e.g. "hostname-12345"
|
|
||||||
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
|
|
||||||
if (orphanPid && isProcessAlive(orphanPid)) {
|
|
||||||
safeKill(orphanPid, 'SIGTERM');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
if (isProcessAlive(orphanPid)) {
|
|
||||||
safeKill(orphanPid, 'SIGKILL');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up Chromium profile locks (can persist after crashes)
|
|
||||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
|
||||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete stale state file
|
// Delete stale state file
|
||||||
safeUnlinkQuiet(config.stateFile);
|
safeUnlinkQuiet(config.stateFile);
|
||||||
|
|
@ -1027,6 +1111,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
});
|
});
|
||||||
const status = await resp.text();
|
const status = await resp.text();
|
||||||
console.log(`Connected to real Chrome\n${status}`);
|
console.log(`Connected to real Chrome\n${status}`);
|
||||||
|
// #1781: surface the window — it often opens behind/on another Space.
|
||||||
|
raiseHeadedWindowMacOS();
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
|
||||||
|
}
|
||||||
|
|
||||||
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
|
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
|
||||||
// the Terminal pane runs an interactive PTY now, no more one-shot
|
// the Terminal pane runs an interactive PTY now, no more one-shot
|
||||||
|
|
@ -1194,11 +1283,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
safeKill(existingState.pid, 'SIGKILL');
|
safeKill(existingState.pid, 'SIGKILL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean profile locks and state file
|
// #1781: killing the daemon can orphan its Chromium child tree, which keeps
|
||||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
// holding the SingletonLock and makes the next `connect` fail to launch.
|
||||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
// Reap the orphan via the lock, then clear the lock files + state.
|
||||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
await killOrphanChromium();
|
||||||
}
|
cleanChromiumProfileLocks();
|
||||||
// Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by
|
// Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by
|
||||||
// cmdline AND start-time), kill it. PID-only would risk killing a
|
// cmdline AND start-time), kill it. PID-only would risk killing a
|
||||||
// recycled PID belonging to an unrelated process.
|
// recycled PID belonging to an unrelated process.
|
||||||
|
|
@ -1258,6 +1347,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendCommand(state, command, commandArgs);
|
await sendCommand(state, command, commandArgs);
|
||||||
|
|
||||||
|
// #1781: `focus` means "show me the window". The server-side focus activates
|
||||||
|
// the page via CDP, but on macOS the app can still sit on another Space — pull
|
||||||
|
// it to the user's current Space too.
|
||||||
|
if (command === 'focus') raiseHeadedWindowMacOS();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { buildRestartEnv } from "../src/cli";
|
||||||
|
|
||||||
|
// #1781: an auto-restart triggered by a plain command (no --headed flag) must
|
||||||
|
// NOT silently downgrade a headed session to headless. buildRestartEnv reapplies
|
||||||
|
// headed/proxy/configHash from this invocation OR the persisted server state.
|
||||||
|
describe("buildRestartEnv (#1781 headed persistence)", () => {
|
||||||
|
const headedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "headed" as const };
|
||||||
|
const launchedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "launched" as const };
|
||||||
|
|
||||||
|
test("headed flag on this invocation → BROWSE_HEADED=1", () => {
|
||||||
|
expect(buildRestartEnv({ headed: true } as any, null).BROWSE_HEADED).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain command + persisted headed state → still BROWSE_HEADED=1 (the regression)", () => {
|
||||||
|
const env = buildRestartEnv({} as any, headedState as any);
|
||||||
|
expect(env.BROWSE_HEADED).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain command + headless state → no BROWSE_HEADED (no spurious headed)", () => {
|
||||||
|
const env = buildRestartEnv({} as any, launchedState as any);
|
||||||
|
expect(env.BROWSE_HEADED).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing set → empty env", () => {
|
||||||
|
expect(buildRestartEnv(null, null)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proxy + configHash reapplied from flags", () => {
|
||||||
|
const env = buildRestartEnv({ proxyUrl: "socks5://x", configHash: "abc" } as any, null);
|
||||||
|
expect(env.BROWSE_PROXY_URL).toBe("socks5://x");
|
||||||
|
expect(env.BROWSE_CONFIG_HASH).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configHash falls back to persisted state", () => {
|
||||||
|
const env = buildRestartEnv({} as any, { ...launchedState, configHash: "fromstate" } as any);
|
||||||
|
expect(env.BROWSE_CONFIG_HASH).toBe("fromstate");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -61,7 +61,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"canary","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"canary","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -171,7 +171,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"codex","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"codex","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -174,7 +174,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"context-restore","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"context-restore","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"context-save","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"context-save","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -174,7 +174,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"cso","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"cso","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -177,7 +177,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: design-consultation
|
name: design-consultation
|
||||||
preamble-tier: 3
|
preamble-tier: 3
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview... (gstack)
|
description: "Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview... (gstack)"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
|
|
@ -87,7 +87,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"design-consultation","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"design-consultation","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -197,7 +197,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: design-html
|
name: design-html
|
||||||
preamble-tier: 2
|
preamble-tier: 2
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Design finalization: generates production-quality Pretext-native HTML/CSS. (gstack)
|
description: "Design finalization: generates production-quality Pretext-native HTML/CSS. (gstack)"
|
||||||
triggers:
|
triggers:
|
||||||
- build the design
|
- build the design
|
||||||
- code the mockup
|
- code the mockup
|
||||||
|
|
@ -68,7 +68,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"design-html","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"design-html","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -178,7 +178,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: design-review
|
name: design-review
|
||||||
preamble-tier: 4
|
preamble-tier: 4
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
description: Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. (gstack)
|
description: "Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. (gstack)"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: design-shotgun
|
name: design-shotgun
|
||||||
preamble-tier: 2
|
preamble-tier: 2
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Design shotgun: generate multiple AI design variants, open a comparison board, collect structured feedback, and iterate. (gstack)
|
description: "Design shotgun: generate multiple AI design variants, open a comparison board, collect structured feedback, and iterate. (gstack)"
|
||||||
triggers:
|
triggers:
|
||||||
- explore design variants
|
- explore design variants
|
||||||
- show me design options
|
- show me design options
|
||||||
|
|
@ -82,7 +82,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"design-shotgun","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"design-shotgun","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -192,7 +192,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -177,7 +177,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"document-generate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"document-generate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -177,7 +177,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"document-release","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"document-release","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
name: guard
|
name: guard
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: Full safety mode: destructive command warnings + directory-scoped edits. (gstack)
|
description: "Full safety mode: destructive command warnings + directory-scoped edits. (gstack)"
|
||||||
triggers:
|
triggers:
|
||||||
- full safety mode
|
- full safety mode
|
||||||
- guard against mistakes
|
- guard against mistakes
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"health","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"health","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"investigate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"investigate","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -212,7 +212,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: ios-clean
|
name: ios-clean
|
||||||
preamble-tier: 3
|
preamble-tier: 3
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app. (gstack)
|
description: "Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app. (gstack)"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ios-clean","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ios-clean","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ios-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ios-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -177,7 +177,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ios-fix","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ios-fix","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -178,7 +178,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ios-qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ios-qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -181,7 +181,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ios-sync","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ios-sync","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"land-and-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"land-and-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -170,7 +170,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"landing-report","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"landing-report","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -171,7 +171,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"learn","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"learn","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,18 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows can't directly spawn the `gbrain` launcher (bun/npm install it as a
|
||||||
|
* `gbrain.cmd`/`.ps1` shim) or a shebang script like the bash `gstack-brain-sync`
|
||||||
|
* — `spawnSync`/`spawn` resolve those only through a shell's PATHEXT + interpreter
|
||||||
|
* lookup. Without `shell: true` the child spawn fails ENOENT, which on the sync
|
||||||
|
* orchestrator surfaced as "brain-sync exited undefined" (#1731). Gate on platform
|
||||||
|
* so POSIX keeps the cheaper no-shell path. Exported so the static-grep tripwire
|
||||||
|
* (test/gbrain-spawn-windows-shell.test.ts) can assert every gbrain/brain-sync
|
||||||
|
* spawn carries it.
|
||||||
|
*/
|
||||||
|
export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32";
|
||||||
|
|
||||||
export interface SpawnGbrainOptions {
|
export interface SpawnGbrainOptions {
|
||||||
/** Timeout in milliseconds. Defaults to 30s. */
|
/** Timeout in milliseconds. Defaults to 30s. */
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|
@ -166,6 +178,7 @@ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): Spaw
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,6 +211,7 @@ export function spawnGbrainAsync(
|
||||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
|
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,5 +226,6 @@ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): s
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* gbrain-guards — defense-in-depth against gbrain's destructive code paths (#1734).
|
||||||
|
*
|
||||||
|
* gbrain (the separate CLI gstack shells out to) can rm-rf a user's working tree
|
||||||
|
* during an autopilot race (its own bug, upstream gbrain #1526). gstack can't fix
|
||||||
|
* that, but it MUST stop treating gbrain's destructive subcommands as safe. These
|
||||||
|
* guards gate the two ways the orchestrator can reach destruction:
|
||||||
|
*
|
||||||
|
* 1. `sources remove --confirm-destructive` → decideSourceRemove()
|
||||||
|
* 2. `sync --strategy code` (can auto-reclone) → decideCodeSync()
|
||||||
|
*
|
||||||
|
* plus an autopilot-active check (detectAutopilot) that refuses to run destructive
|
||||||
|
* ops concurrently with the daemon.
|
||||||
|
*
|
||||||
|
* Design notes grounded in the real gbrain 0.41.x surface:
|
||||||
|
* - There is NO `--keep-storage` flag and NO structured capability command, and
|
||||||
|
* subcommand `--help` is generic — so capability detection is best-effort and
|
||||||
|
* defaults to "unsupported". When we can't protect a user-managed source's
|
||||||
|
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
|
||||||
|
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
|
||||||
|
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
|
||||||
|
* known lock paths under both the configured home and ~/.gbrain are secondary.
|
||||||
|
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
|
||||||
|
* never blocks a normal sync (that would brick the tool).
|
||||||
|
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
|
||||||
|
* smuggle a delete out to a user repo.
|
||||||
|
*
|
||||||
|
* Pure decision functions; the orchestrator logs the reasons (observability).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { existsSync, realpathSync } from "fs";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { join, resolve, sep } from "path";
|
||||||
|
import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||||
|
import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources";
|
||||||
|
|
||||||
|
export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
return env.GBRAIN_HOME || join(homedir(), ".gbrain");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories gbrain owns and may delete safely. A source whose local_path
|
||||||
|
* resolves inside one of these is gbrain-managed; outside = user-managed and
|
||||||
|
* must be protected. Both the configured home and the default ~/.gbrain are
|
||||||
|
* checked because gbrain #1226 shows home-resolution is inconsistent.
|
||||||
|
*/
|
||||||
|
function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||||
|
return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */
|
||||||
|
export function isInside(p: string, dir: string): boolean {
|
||||||
|
let rp: string;
|
||||||
|
let rd: string;
|
||||||
|
try { rp = realpathSync(p); } catch { rp = resolve(p); }
|
||||||
|
try { rd = realpathSync(dir); } catch { rd = resolve(dir); }
|
||||||
|
const base = rd.endsWith(sep) ? rd : rd + sep;
|
||||||
|
return rp === rd || rp.startsWith(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autopilot detection (E1: multi-signal, affirmative-only) ────────────────
|
||||||
|
|
||||||
|
export interface AutopilotStatus {
|
||||||
|
active: boolean;
|
||||||
|
/** Which signal fired (lock path or "process"), or null when inactive. */
|
||||||
|
signal: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutopilotProbe {
|
||||||
|
/** Override the lock-path list (tests). */
|
||||||
|
lockPaths?: string[];
|
||||||
|
/** Override the live-process check (tests). */
|
||||||
|
processRunning?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a running gbrain autopilot. Refuse the caller's destructive op only on
|
||||||
|
* an affirmative signal; absence of a confirmable mechanism returns inactive so
|
||||||
|
* normal syncs are never bricked.
|
||||||
|
*/
|
||||||
|
export function detectAutopilot(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
probe: AutopilotProbe = {},
|
||||||
|
): AutopilotStatus {
|
||||||
|
// Secondary signal: known lock files. gbrain #1226 — the lock ignores
|
||||||
|
// GBRAIN_HOME, so check both the configured home and the default ~/.gbrain.
|
||||||
|
const lockPaths = probe.lockPaths ?? [
|
||||||
|
join(gbrainHome(env), "autopilot.lock"),
|
||||||
|
join(homedir(), ".gbrain", "autopilot.lock"),
|
||||||
|
join(gbrainHome(env), "autopilot.pid"),
|
||||||
|
join(homedir(), ".gbrain", "autopilot.pid"),
|
||||||
|
];
|
||||||
|
for (const lp of lockPaths) {
|
||||||
|
if (existsSync(lp)) return { active: true, signal: `lock:${lp}` };
|
||||||
|
}
|
||||||
|
// Primary signal: a live `gbrain autopilot` process.
|
||||||
|
const running = (probe.processRunning ?? defaultProcessRunning)();
|
||||||
|
if (running) return { active: true, signal: "process:gbrain autopilot" };
|
||||||
|
return { active: false, signal: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProcessRunning(): boolean {
|
||||||
|
// No reliable pgrep on Windows; rely on the lock-file signal there.
|
||||||
|
if (process.platform === "win32") return false;
|
||||||
|
const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
|
||||||
|
return r.status === 0 && (r.stdout || "").trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
|
||||||
|
//
|
||||||
|
// No structured capability command exists and subcommand --help is generic, so
|
||||||
|
// --keep-storage support can't be probed reliably; default unsupported. Memoize
|
||||||
|
// per process (keyed to the resolved gbrain identity) rather than persisting a
|
||||||
|
// cross-run cache — Codex flagged stale persistent caches, and the probe is cheap.
|
||||||
|
|
||||||
|
let _keepStorageMemo: { key: string; value: boolean } | undefined;
|
||||||
|
|
||||||
|
function gbrainIdentity(env: NodeJS.ProcessEnv): string {
|
||||||
|
const r = spawnSync("gbrain", ["--version"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 3_000,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return (r.stdout || "").trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
const key = gbrainIdentity(env);
|
||||||
|
if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value;
|
||||||
|
let value = false;
|
||||||
|
for (const args of [["sources", "remove", "--help"], ["--help"]]) {
|
||||||
|
try {
|
||||||
|
if (/--keep-storage/.test(execGbrainText(args, { baseEnv: env, timeout: 5_000 }))) {
|
||||||
|
value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// generic/empty help or non-zero exit → treat as unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_keepStorageMemo = { key, value };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: reset the per-process capability memo. */
|
||||||
|
export function _resetCapabilityMemo(): void {
|
||||||
|
_keepStorageMemo = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destructive-op decisions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch + normalize the source list. Throws on read/parse failure so callers can
|
||||||
|
* distinguish "couldn't read" (fail closed) from "empty list" (source absent).
|
||||||
|
* Injectable for hermetic tests.
|
||||||
|
*/
|
||||||
|
export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] {
|
||||||
|
const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env });
|
||||||
|
if (raw === null) throw new Error("gbrain sources list returned no JSON");
|
||||||
|
return parseSourcesList(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveDecision {
|
||||||
|
allow: boolean;
|
||||||
|
/** Extra args to append to `sources remove` (e.g. --keep-storage). */
|
||||||
|
extraArgs: string[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether `sources remove <id>` is safe, and with what flags.
|
||||||
|
*
|
||||||
|
* Fail-closed cases (allow=false):
|
||||||
|
* - sources list unreadable/unparseable (can't prove the row is safe).
|
||||||
|
* - the row is user-managed (remote_url set AND local_path outside gbrain's
|
||||||
|
* clones) and gbrain has no --keep-storage to protect the files.
|
||||||
|
*
|
||||||
|
* Allowed: absent row (no-op), gbrain-managed (inside clones), or path-managed
|
||||||
|
* without a remote_url (gbrain's remove won't touch an outside-clones path that
|
||||||
|
* it didn't clone). --keep-storage is appended whenever supported, as extra armor.
|
||||||
|
*/
|
||||||
|
export interface DecideRemoveOpts {
|
||||||
|
/** Override capability detection (tests / cached caps). */
|
||||||
|
keepStorage?: boolean;
|
||||||
|
/** Override the source-list fetch (tests). Throwing simulates a read failure. */
|
||||||
|
fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideSourceRemove(
|
||||||
|
sourceId: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
opts: DecideRemoveOpts = {},
|
||||||
|
): RemoveDecision {
|
||||||
|
const keepStorage = opts.keepStorage ?? gbrainSupportsKeepStorage(env);
|
||||||
|
const extra = keepStorage ? ["--keep-storage"] : [];
|
||||||
|
|
||||||
|
let rows: GbrainSourceRow[];
|
||||||
|
try {
|
||||||
|
rows = (opts.fetchRows ?? fetchSources)(env);
|
||||||
|
} catch {
|
||||||
|
return { allow: false, extraArgs: [], reason: "could not read sources list; refusing remove (fail closed)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = rows.find((r) => r.id === sourceId);
|
||||||
|
if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" };
|
||||||
|
|
||||||
|
const remoteUrl = row.config?.remote_url;
|
||||||
|
const userManaged =
|
||||||
|
!!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d));
|
||||||
|
|
||||||
|
if (userManaged) {
|
||||||
|
if (keepStorage) {
|
||||||
|
return { allow: true, extraArgs: ["--keep-storage"], reason: "user-managed; --keep-storage protects files" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allow: false,
|
||||||
|
extraArgs: [],
|
||||||
|
reason:
|
||||||
|
`refusing remove of user-managed source "${sourceId}" (remote_url set, local_path ` +
|
||||||
|
`${row.local_path} outside gbrain clones) — this gbrain has no --keep-storage to ` +
|
||||||
|
`protect the working tree. Upgrade gbrain or remove the source manually.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allow: true, extraArgs: extra, reason: "gbrain-managed or path-managed without remote_url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncDecision {
|
||||||
|
allow: boolean;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether `sync --strategy code --source <id>` is safe to run.
|
||||||
|
*
|
||||||
|
* A source with a remote_url can trigger gbrain's auto-reclone, the ungated
|
||||||
|
* rm-rf path behind the data loss (gbrain #1526). Require an explicit
|
||||||
|
* --allow-reclone opt-in for URL-managed sources. Read failure here is NOT
|
||||||
|
* itself destructive, so it fails open (proceed) — the autopilot guard, checked
|
||||||
|
* first, is the primary protection against the race that caused the loss.
|
||||||
|
*/
|
||||||
|
export function decideCodeSync(
|
||||||
|
sourceId: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
allowReclone = false,
|
||||||
|
fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources,
|
||||||
|
): SyncDecision {
|
||||||
|
let rows: GbrainSourceRow[];
|
||||||
|
try {
|
||||||
|
rows = fetchRows(env);
|
||||||
|
} catch {
|
||||||
|
return { allow: true, reason: "sources unreadable; proceeding (sync read is non-destructive)" };
|
||||||
|
}
|
||||||
|
const row = rows.find((r) => r.id === sourceId);
|
||||||
|
if (row?.config?.remote_url && !allowReclone) {
|
||||||
|
return {
|
||||||
|
allow: false,
|
||||||
|
reason:
|
||||||
|
`source "${sourceId}" is URL-managed (remote_url set); sync may auto-reclone and ` +
|
||||||
|
`delete the working tree. Re-run /sync-gbrain with --allow-reclone to proceed.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { allow: true, reason: "no remote_url, or reclone explicitly allowed" };
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ import {
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { buildGbrainEnv } from "./gbrain-exec";
|
import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||||
|
|
||||||
export type LocalEngineStatus =
|
export type LocalEngineStatus =
|
||||||
| "ok"
|
| "ok"
|
||||||
|
|
@ -113,6 +113,7 @@ export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
|
||||||
timeout: 2_000,
|
timeout: 2_000,
|
||||||
stdio: ["ignore", "ignore", "ignore"],
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
env: e,
|
env: e,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
result = "gbrain";
|
result = "gbrain";
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -135,6 +136,7 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
|
||||||
timeout: 2_000,
|
timeout: 2_000,
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
env: e,
|
env: e,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
result = out.trim().split("\n")[0] || "";
|
result = out.trim().split("\n")[0] || "";
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -241,6 +243,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
|
||||||
timeout: PROBE_TIMEOUT_MS,
|
timeout: PROBE_TIMEOUT_MS,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: buildGbrainEnv({ baseEnv: env ?? process.env }),
|
env: buildGbrainEnv({ baseEnv: env ?? process.env }),
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
return "ok";
|
return "ok";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
import { execFileSync, spawnSync } from "child_process";
|
import { execFileSync, spawnSync } from "child_process";
|
||||||
import { withErrorContext } from "./gstack-memory-helpers";
|
import { withErrorContext } from "./gstack-memory-helpers";
|
||||||
|
import { NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||||
|
|
||||||
export interface SourceState {
|
export interface SourceState {
|
||||||
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
||||||
|
|
@ -26,6 +27,37 @@ export interface EnsureResult {
|
||||||
state: SourceState;
|
state: SourceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `gbrain sources list --json`. `config.remote_url` distinguishes
|
||||||
|
* URL-managed sources (gbrain owns the clone, may auto-reclone) from
|
||||||
|
* path-managed ones (user owns the working tree) — load-bearing for the #1734
|
||||||
|
* destructive-op guards.
|
||||||
|
*/
|
||||||
|
export interface GbrainSourceRow {
|
||||||
|
id?: string;
|
||||||
|
local_path?: string;
|
||||||
|
page_count?: number;
|
||||||
|
config?: { remote_url?: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize `gbrain sources list --json` output to an array of source rows.
|
||||||
|
*
|
||||||
|
* gbrain has shipped two shapes: a wrapped `{ sources: [...] }` object (v0.20+)
|
||||||
|
* and, in older/other variants, a bare top-level array. #1576 was a crash when a
|
||||||
|
* reader assumed one shape; the parse is centralized here so every reader
|
||||||
|
* (probeSource, sourcePageCount, sourceLocalPath, the #1734 remote_url audit)
|
||||||
|
* agrees on the shape in ONE place. Returns [] for null/garbage rather than
|
||||||
|
* throwing — callers treat "no rows" as absent.
|
||||||
|
*/
|
||||||
|
export function parseSourcesList(raw: unknown): GbrainSourceRow[] {
|
||||||
|
if (Array.isArray(raw)) return raw as GbrainSourceRow[];
|
||||||
|
if (raw && typeof raw === "object" && Array.isArray((raw as { sources?: unknown }).sources)) {
|
||||||
|
return (raw as { sources: GbrainSourceRow[] }).sources;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnsureOptions {
|
export interface EnsureOptions {
|
||||||
/** Pass --federated to `gbrain sources add`. Default false. */
|
/** Pass --federated to `gbrain sources add`. Default false. */
|
||||||
federated?: boolean;
|
federated?: boolean;
|
||||||
|
|
@ -56,6 +88,7 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||||
|
|
@ -69,14 +102,14 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: { sources?: Array<{ id?: string; local_path?: string }> };
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(stdout);
|
parsed = JSON.parse(stdout);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`gbrain sources list returned non-JSON output: ${(err as Error).message}`);
|
throw new Error(`gbrain sources list returned non-JSON output: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = parsed.sources || [];
|
const sources = parseSourcesList(parsed);
|
||||||
const match = sources.find((s) => s.id === id);
|
const match = sources.find((s) => s.id === id);
|
||||||
if (!match) return { status: "absent" };
|
if (!match) return { status: "absent" };
|
||||||
return {
|
return {
|
||||||
|
|
@ -129,6 +162,7 @@ export async function ensureSourceRegistered(
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
env,
|
env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
if (rm.status !== 0) {
|
if (rm.status !== 0) {
|
||||||
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
|
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
|
||||||
|
|
@ -142,6 +176,7 @@ export async function ensureSourceRegistered(
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
env,
|
env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
if (add.status !== 0) {
|
if (add.status !== 0) {
|
||||||
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
|
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
|
||||||
|
|
@ -167,14 +202,14 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stdout) as { sources?: Array<{ id?: string; page_count?: number }> };
|
const match = parseSourcesList(JSON.parse(stdout)).find((s) => s.id === id);
|
||||||
const match = (parsed.sources || []).find((s) => s.id === id);
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
if (typeof match.page_count !== "number") return null;
|
if (typeof match.page_count !== "number") return null;
|
||||||
return match.page_count;
|
return match.page_count;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"make-pdf","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"make-pdf","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -208,7 +208,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -208,7 +208,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"open-gstack-browser","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"open-gstack-browser","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -170,7 +170,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.54.0.0",
|
"version": "1.55.1.0",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"pair-agent","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"pair-agent","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -172,7 +172,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -202,7 +202,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"plan-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"plan-design-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -174,7 +174,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"plan-devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"plan-devex-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -180,7 +180,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"plan-eng-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"plan-eng-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -178,7 +178,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: plan-tune
|
name: plan-tune
|
||||||
preamble-tier: 2
|
preamble-tier: 2
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). (gstack)
|
description: "Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). (gstack)"
|
||||||
triggers:
|
triggers:
|
||||||
- tune questions
|
- tune questions
|
||||||
- stop asking me that
|
- stop asking me that
|
||||||
|
|
@ -73,7 +73,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"plan-tune","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"plan-tune","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -183,7 +183,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"qa-only","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"qa-only","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"qa","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -179,7 +179,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"retro","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"retro","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -190,7 +190,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"scrape","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"scrape","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -171,7 +171,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,28 @@ export function buildWhenToInvokeSection(parts: CatalogParts): string {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a string as a YAML inline scalar value (the text after `key: `),
|
||||||
|
* quoting only when a plain scalar would be invalid or ambiguous.
|
||||||
|
*
|
||||||
|
* The bug this guards (#1778): a description like "Ship workflow: detect..."
|
||||||
|
* emitted as a plain scalar has an interior ": " that a strict YAML parser
|
||||||
|
* (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
|
||||||
|
* "mapping values are not allowed in this context". When quoting is needed we
|
||||||
|
* fall back to JSON.stringify, which produces a double-quoted scalar that YAML
|
||||||
|
* accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
|
||||||
|
* are already valid plain scalars pass through unchanged to keep regen diffs small.
|
||||||
|
*/
|
||||||
|
export function toYamlInlineScalar(s: string): string {
|
||||||
|
const needsQuote =
|
||||||
|
s.length === 0 ||
|
||||||
|
s !== s.trim() || // leading/trailing whitespace
|
||||||
|
/:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
|
||||||
|
/\s#/.test(s) || // " #" → inline comment
|
||||||
|
/^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
|
||||||
|
return needsQuote ? JSON.stringify(s) : s;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply catalog trim to a SKILL.md body:
|
* Apply catalog trim to a SKILL.md body:
|
||||||
* - shorten frontmatter `description:` to lead + (gstack)
|
* - shorten frontmatter `description:` to lead + (gstack)
|
||||||
|
|
@ -397,8 +419,16 @@ export function applyCatalogTrim(content: string, skillName: string): { content:
|
||||||
|
|
||||||
// Replace description in frontmatter — keep trailing newline so the next
|
// Replace description in frontmatter — keep trailing newline so the next
|
||||||
// YAML field doesn't collide on the same line as the description value.
|
// YAML field doesn't collide on the same line as the description value.
|
||||||
|
// Quote the value when it would be an invalid YAML plain scalar (the common
|
||||||
|
// case: an interior ": " like "Ship workflow: detect..." which a strict YAML
|
||||||
|
// parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
|
||||||
|
// only quotes when needed, so descriptions without special chars stay plain.
|
||||||
const newDesc = buildTrimmedDescription(parts);
|
const newDesc = buildTrimmedDescription(parts);
|
||||||
const newFrontmatter = frontmatter.replace(descMatch[0], `description: ${newDesc}\n`);
|
// Function replacer (not a string) so a `$` in the description — e.g. a future
|
||||||
|
// skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
|
||||||
|
// pattern and silently corrupt the frontmatter.
|
||||||
|
const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
|
||||||
|
const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
|
||||||
let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
|
let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
|
||||||
|
|
||||||
// Insert body section after frontmatter (after the closing ---\n and any
|
// Insert body section after frontmatter (after the closing ---\n and any
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ _QUESTION_TUNING=$(${ctx.paths.binDir}/gstack-config get question_tuning 2>/dev/
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "\${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { TemplateContext } from '../types';
|
||||||
export function generateTelemetryPrompt(ctx: TemplateContext): string {
|
export function generateTelemetryPrompt(ctx: TemplateContext): string {
|
||||||
return `If \`TEL_PROMPTED\` is \`no\` AND \`LAKE_INTRO\` is \`yes\`: ask telemetry once via AskUserQuestion:
|
return `If \`TEL_PROMPTED\` is \`no\` AND \`LAKE_INTRO\` is \`yes\`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"setup-browser-cookies","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"setup-browser-cookies","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -167,7 +167,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"setup-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"setup-deploy","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -174,7 +174,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: setup-gbrain
|
name: setup-gbrain
|
||||||
preamble-tier: 2
|
preamble-tier: 2
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy. (gstack)
|
description: "Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy. (gstack)"
|
||||||
triggers:
|
triggers:
|
||||||
- setup gbrain
|
- setup gbrain
|
||||||
- install gbrain
|
- install gbrain
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"setup-gbrain","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"setup-gbrain","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: ship
|
name: ship
|
||||||
preamble-tier: 4
|
preamble-tier: 4
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)
|
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"skillify","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"skillify","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -171,7 +171,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"spec","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"spec","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -172,7 +172,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
@ -1073,7 +1073,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"spec","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"spec","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -1183,7 +1183,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"sync-gbrain","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"sync-gbrain","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -173,7 +173,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
@ -990,6 +990,12 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
|
||||||
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
||||||
machine — gbrain's daemon handles incremental refresh on a schedule.
|
machine — gbrain's daemon handles incremental refresh on a schedule.
|
||||||
|
|
||||||
|
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
|
||||||
|
orchestrator refuses destructive source ops when it detects a running autopilot
|
||||||
|
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
|
||||||
|
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
|
||||||
|
sync code walk for them requires an explicit `--allow-reclone` opt-in.
|
||||||
|
|
||||||
<!-- gstack-gbrain-search-guidance:end -->
|
<!-- gstack-gbrain-search-guidance:end -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,12 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
|
||||||
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
||||||
machine — gbrain's daemon handles incremental refresh on a schedule.
|
machine — gbrain's daemon handles incremental refresh on a schedule.
|
||||||
|
|
||||||
|
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
|
||||||
|
orchestrator refuses destructive source ops when it detects a running autopilot
|
||||||
|
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
|
||||||
|
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
|
||||||
|
sync code walk for them requires an explicit `--allow-reclone` opt-in.
|
||||||
|
|
||||||
<!-- gstack-gbrain-search-guidance:end -->
|
<!-- gstack-gbrain-search-guidance:end -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
|
||||||
test('--catalog-mode=full produces multi-line description in frontmatter', () => {
|
test('--catalog-mode=full produces multi-line description in frontmatter', () => {
|
||||||
// Save the trim'd state so we can restore it.
|
// Save the trim'd state so we can restore it.
|
||||||
const trimmedShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
const trimmedShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
||||||
expect(trimmedShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m);
|
// #1778: the trimmed ship description has an interior colon ("Ship workflow:")
|
||||||
|
// and is now YAML-quoted — tolerate the optional surrounding quotes.
|
||||||
|
expect(trimmedShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run with --catalog-mode=full. Mutates working tree.
|
// Run with --catalog-mode=full. Mutates working tree.
|
||||||
|
|
@ -100,7 +102,8 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
|
||||||
}
|
}
|
||||||
// Sanity-check the restored state matches what we saw at the start.
|
// Sanity-check the restored state matches what we saw at the start.
|
||||||
const restoredShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
const restoredShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
|
||||||
expect(restoredShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m);
|
// #1778: restored trim state has the YAML-quoted (interior-colon) description.
|
||||||
|
expect(restoredShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
|
||||||
}
|
}
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,8 +227,10 @@ Original body content here.
|
||||||
const result = applyCatalogTrim(minimalSkill, 'example');
|
const result = applyCatalogTrim(minimalSkill, 'example');
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
const { content, parts } = result!;
|
const { content, parts } = result!;
|
||||||
// Frontmatter description is now ONE line ending with (gstack)
|
// Frontmatter description is now ONE line ending with (gstack). #1778: a
|
||||||
expect(content).toMatch(/^description: Example skill:[^\n]*\(gstack\)\n/m);
|
// description with an interior colon ("Example skill:") is YAML-quoted, so
|
||||||
|
// the value is wrapped in double quotes — tolerate the optional quotes.
|
||||||
|
expect(content).toMatch(/^description: "?Example skill:[^\n]*\(gstack\)"?\n/m);
|
||||||
// Body has the When to invoke section
|
// Body has the When to invoke section
|
||||||
expect(content).toContain('## When to invoke this skill');
|
expect(content).toContain('## When to invoke this skill');
|
||||||
expect(content).toContain('Use when asked to do an example task.');
|
expect(content).toContain('Use when asked to do an example task.');
|
||||||
|
|
@ -257,7 +259,8 @@ Original body content here.
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/);
|
expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/);
|
||||||
expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/);
|
expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/);
|
||||||
expect(result!.content).toMatch(/\(gstack\)\n[a-z-]+:/);
|
// #1778: optional closing quote when the description was YAML-quoted.
|
||||||
|
expect(result!.content).toMatch(/\(gstack\)"?\n[a-z-]+:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns null on content without proper frontmatter', () => {
|
test('returns null on content without proper frontmatter', () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: ship
|
name: ship
|
||||||
preamble-tier: 4
|
preamble-tier: 4
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)
|
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
|
|
@ -65,7 +65,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -175,7 +175,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ _QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null ||
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -161,7 +161,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ _QUESTION_TUNING=$($GSTACK_BIN/gstack-config get question_tuning 2>/dev/null ||
|
||||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||||
mkdir -p ~/.gstack/analytics
|
mkdir -p ~/.gstack/analytics
|
||||||
if [ "$_TEL" != "off" ]; then
|
if [ "$_TEL" != "off" ]; then
|
||||||
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
echo '{"skill":"ship","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||||
if [ -f "$_PF" ]; then
|
if [ -f "$_PF" ]; then
|
||||||
|
|
@ -163,7 +163,7 @@ Only run `open` if yes. Always run `touch`.
|
||||||
|
|
||||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||||
|
|
||||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- A) Help gstack get better! (recommended)
|
- A) Help gstack get better! (recommended)
|
||||||
|
|
|
||||||
|
|
@ -204,14 +204,30 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
test('passes when install-dir version matches `gbrain --version` on PATH', () => {
|
test('passes when install-dir version matches `gbrain --version` on PATH', () => {
|
||||||
|
// Version must be >= MIN_GBRAIN_VERSION (0.20.0) floor (#1744).
|
||||||
|
const installDir = seedInstallDir('0.41.29');
|
||||||
|
const fakeBin = seedFakeGbrainBinary('0.41.29');
|
||||||
|
try {
|
||||||
|
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||||
|
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain('installed gbrain 0.41.29');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(installDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard-fails (exit 3) when the installed gbrain is below the version floor (#1744)', () => {
|
||||||
const installDir = seedInstallDir('0.18.2');
|
const installDir = seedInstallDir('0.18.2');
|
||||||
const fakeBin = seedFakeGbrainBinary('0.18.2');
|
const fakeBin = seedFakeGbrainBinary('0.18.2');
|
||||||
try {
|
try {
|
||||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(3);
|
||||||
expect(r.stdout).toContain('installed gbrain 0.18.2');
|
expect(r.stderr).toContain('below the minimum gstack-tested version');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(installDir, { recursive: true, force: true });
|
fs.rmSync(installDir, { recursive: true, force: true });
|
||||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||||
|
|
@ -219,8 +235,8 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tolerates a leading "v" in `gbrain --version` output', () => {
|
test('tolerates a leading "v" in `gbrain --version` output', () => {
|
||||||
const installDir = seedInstallDir('0.18.2');
|
const installDir = seedInstallDir('0.41.29');
|
||||||
const fakeBin = seedFakeGbrainBinary('v0.18.2');
|
const fakeBin = seedFakeGbrainBinary('v0.41.29');
|
||||||
try {
|
try {
|
||||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { describe, test, expect, afterEach } from "bun:test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
detectAutopilot,
|
||||||
|
decideSourceRemove,
|
||||||
|
decideCodeSync,
|
||||||
|
isInside,
|
||||||
|
_resetCapabilityMemo,
|
||||||
|
type GbrainSourceRow,
|
||||||
|
} from "../lib/gbrain-guards";
|
||||||
|
|
||||||
|
const HOME = os.homedir();
|
||||||
|
const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name);
|
||||||
|
|
||||||
|
afterEach(() => _resetCapabilityMemo());
|
||||||
|
|
||||||
|
// ── #1734 autopilot detection (E1: affirmative multi-signal) ────────────────
|
||||||
|
describe("detectAutopilot", () => {
|
||||||
|
test("refuses on a present lock file (secondary signal)", () => {
|
||||||
|
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
|
||||||
|
const lock = join(tmp, "autopilot.lock");
|
||||||
|
fs.writeFileSync(lock, "");
|
||||||
|
const r = detectAutopilot(process.env, { lockPaths: [lock], processRunning: () => false });
|
||||||
|
expect(r.active).toBe(true);
|
||||||
|
expect(r.signal).toContain("lock:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses on a live autopilot process (primary signal)", () => {
|
||||||
|
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => true });
|
||||||
|
expect(r.active).toBe(true);
|
||||||
|
expect(r.signal).toBe("process:gbrain autopilot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proceeds when no signal fires (never blanket-refuses)", () => {
|
||||||
|
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => false });
|
||||||
|
expect(r.active).toBe(false);
|
||||||
|
expect(r.signal).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─
|
||||||
|
describe("decideSourceRemove", () => {
|
||||||
|
const rows = (extra: GbrainSourceRow[] = []): GbrainSourceRow[] => [
|
||||||
|
{ id: "gbrain-managed", local_path: clonesPath("repo"), config: { remote_url: "https://x/r.git" } },
|
||||||
|
{ id: "user-managed", local_path: "/tmp/user-repo", config: { remote_url: "https://x/r.git" } },
|
||||||
|
{ id: "path-managed", local_path: "/tmp/path-repo" }, // no remote_url
|
||||||
|
...extra,
|
||||||
|
];
|
||||||
|
const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra);
|
||||||
|
|
||||||
|
test("absent source → allow (no-op)", () => {
|
||||||
|
const d = decideSourceRemove("nope", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
expect(d.reason).toContain("absent");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user-managed + no --keep-storage → FAIL CLOSED", () => {
|
||||||
|
const d = decideSourceRemove("user-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||||
|
expect(d.allow).toBe(false);
|
||||||
|
expect(d.reason).toContain("user-managed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user-managed + --keep-storage supported → allow with flag", () => {
|
||||||
|
const d = decideSourceRemove("user-managed", process.env, { keepStorage: true, fetchRows: fetchRows() });
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
expect(d.extraArgs).toContain("--keep-storage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gbrain-managed (inside clones) → allow even without keep-storage", () => {
|
||||||
|
const d = decideSourceRemove("gbrain-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("path-managed without remote_url → allow (normal --path case)", () => {
|
||||||
|
const d = decideSourceRemove("path-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sources unreadable → FAIL CLOSED", () => {
|
||||||
|
const d = decideSourceRemove("user-managed", process.env, {
|
||||||
|
keepStorage: false,
|
||||||
|
fetchRows: () => { throw new Error("boom"); },
|
||||||
|
});
|
||||||
|
expect(d.allow).toBe(false);
|
||||||
|
expect(d.reason).toContain("fail closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── #1734 reclone guard (E-level: require --allow-reclone for URL-managed) ───
|
||||||
|
describe("decideCodeSync", () => {
|
||||||
|
const rows: GbrainSourceRow[] = [
|
||||||
|
{ id: "url-managed", local_path: "/tmp/u", config: { remote_url: "https://x/r.git" } },
|
||||||
|
{ id: "plain", local_path: "/tmp/p" },
|
||||||
|
];
|
||||||
|
const fetch = () => rows;
|
||||||
|
|
||||||
|
test("URL-managed + no --allow-reclone → refuse", () => {
|
||||||
|
const d = decideCodeSync("url-managed", process.env, false, fetch);
|
||||||
|
expect(d.allow).toBe(false);
|
||||||
|
expect(d.reason).toContain("auto-reclone");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("URL-managed + --allow-reclone → allow", () => {
|
||||||
|
const d = decideCodeSync("url-managed", process.env, true, fetch);
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no remote_url → allow", () => {
|
||||||
|
const d = decideCodeSync("plain", process.env, false, fetch);
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sources unreadable → fail OPEN (sync read is non-destructive)", () => {
|
||||||
|
const d = decideCodeSync("url-managed", process.env, false, () => { throw new Error("boom"); });
|
||||||
|
expect(d.allow).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── path containment uses realpath (symlink can't smuggle a delete out) ──────
|
||||||
|
describe("isInside", () => {
|
||||||
|
test("plain path inside dir", () => {
|
||||||
|
expect(isInside("/a/b/c", "/a/b")).toBe(true);
|
||||||
|
expect(isInside("/a/x", "/a/b")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling-prefix is not 'inside' (clonesX vs clones)", () => {
|
||||||
|
expect(isInside("/a/clones-evil/x", "/a/clones")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("symlink pointing outside resolves outside", () => {
|
||||||
|
const base = fs.mkdtempSync(join(os.tmpdir(), "clones-"));
|
||||||
|
const outside = fs.mkdtempSync(join(os.tmpdir(), "outside-"));
|
||||||
|
const link = join(base, "sneaky");
|
||||||
|
fs.symlinkSync(outside, link);
|
||||||
|
// link lives under base, but realpath resolves to `outside` → not inside base.
|
||||||
|
expect(isInside(link, base)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { parseSourcesList } from "../lib/gbrain-sources";
|
||||||
|
|
||||||
|
// #1576 hardening: `gbrain sources list --json` has shipped two shapes — a
|
||||||
|
// wrapped `{ sources: [...] }` object (v0.20+) and a bare top-level array.
|
||||||
|
// parseSourcesList is the single place that normalizes both, so every reader
|
||||||
|
// (probeSource, sourcePageCount, sourceLocalPath, the #1734 remote_url audit)
|
||||||
|
// agrees on the shape. These tests pin both shapes plus the garbage paths.
|
||||||
|
describe("parseSourcesList", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "a", local_path: "/x", page_count: 3 },
|
||||||
|
{ id: "b", local_path: "/y", config: { remote_url: "https://example.com/r.git" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("wrapped { sources: [...] } shape", () => {
|
||||||
|
expect(parseSourcesList({ sources: rows })).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bare top-level array shape", () => {
|
||||||
|
expect(parseSourcesList(rows)).toEqual(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both shapes yield identical rows (shape-independent)", () => {
|
||||||
|
expect(parseSourcesList({ sources: rows })).toEqual(parseSourcesList(rows));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null / undefined → empty array (no throw)", () => {
|
||||||
|
expect(parseSourcesList(null)).toEqual([]);
|
||||||
|
expect(parseSourcesList(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object without sources key → empty array", () => {
|
||||||
|
expect(parseSourcesList({ pages: [] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sources key present but not an array → empty array", () => {
|
||||||
|
expect(parseSourcesList({ sources: "oops" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scalar garbage → empty array", () => {
|
||||||
|
expect(parseSourcesList("nope")).toEqual([]);
|
||||||
|
expect(parseSourcesList(42)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves config.remote_url for the #1734 audit", () => {
|
||||||
|
const parsed = parseSourcesList({ sources: rows });
|
||||||
|
expect(parsed.find((r) => r.id === "b")?.config?.remote_url).toBe("https://example.com/r.git");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, "..");
|
||||||
|
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||||
|
|
||||||
|
// #1731 tripwire. Windows can't spawn the `gbrain` shim (gbrain.cmd) or the bash
|
||||||
|
// shebang script gstack-brain-sync without a shell; the fix gates `shell: true`
|
||||||
|
// behind NEEDS_SHELL_ON_WINDOWS. These static checks fail CI if a refactor adds
|
||||||
|
// a gbrain/brain-sync child spawn without the Windows shell flag, since macOS/
|
||||||
|
// Linux CI can't exercise the Windows path at runtime.
|
||||||
|
describe("#1731 gbrain spawns carry the Windows shell flag", () => {
|
||||||
|
test("NEEDS_SHELL_ON_WINDOWS is platform-gated in gbrain-exec.ts", () => {
|
||||||
|
const src = read("lib/gbrain-exec.ts");
|
||||||
|
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every direct `gbrain` child spawn in these files must be matched by a
|
||||||
|
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
|
||||||
|
// refactor-resistant invariant.
|
||||||
|
const gbrainSpawnFiles = [
|
||||||
|
"lib/gbrain-exec.ts",
|
||||||
|
"lib/gbrain-sources.ts",
|
||||||
|
"lib/gbrain-local-status.ts",
|
||||||
|
];
|
||||||
|
for (const rel of gbrainSpawnFiles) {
|
||||||
|
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
|
||||||
|
const src = read(rel);
|
||||||
|
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
|
||||||
|
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
|
||||||
|
expect(spawnOpeners).toBeGreaterThan(0);
|
||||||
|
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("orchestrator brain-sync spawns carry the Windows shell flag", () => {
|
||||||
|
const src = read("bin/gstack-gbrain-sync.ts");
|
||||||
|
const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0;
|
||||||
|
expect(brainSyncSpawns).toBe(2);
|
||||||
|
// Both spawnSync(brainSyncPath, ...) blocks must include the shell flag.
|
||||||
|
const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
|
||||||
|
expect(withShell).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -173,12 +173,39 @@ describe('gen-skill-docs', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every generated SKILL.md has valid YAML frontmatter', () => {
|
// #1778: strict YAML parsers (Codex/OpenAI skill loading) reject frontmatter
|
||||||
|
// whose plain `description:` scalar contains an interior ": " (read as a nested
|
||||||
|
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
|
||||||
|
// not just string-check that name:/description: exist.
|
||||||
|
function frontmatterBlock(content: string): string {
|
||||||
|
expect(content.startsWith('---\n')).toBe(true);
|
||||||
|
const end = content.indexOf('\n---', 4);
|
||||||
|
expect(end).toBeGreaterThan(0);
|
||||||
|
return content.slice(4, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
|
||||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||||
expect(content.startsWith('---\n')).toBe(true);
|
const fm = frontmatterBlock(content);
|
||||||
expect(content).toContain('name:');
|
let parsed: any;
|
||||||
expect(content).toContain('description:');
|
expect(() => { parsed = Bun.YAML.parse(fm); },
|
||||||
|
`frontmatter for ${skill.dir} must be valid YAML`).not.toThrow();
|
||||||
|
expect(typeof parsed?.name).toBe('string');
|
||||||
|
expect(typeof parsed?.description).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
|
||||||
|
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||||
|
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
|
||||||
|
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const mdPath = path.join(agentsDir, entry.name, 'SKILL.md');
|
||||||
|
if (!fs.existsSync(mdPath)) continue;
|
||||||
|
const fm = frontmatterBlock(fs.readFileSync(mdPath, 'utf-8'));
|
||||||
|
expect(() => Bun.YAML.parse(fm),
|
||||||
|
`Codex frontmatter for ${entry.name} must be valid YAML`).not.toThrow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* gstack-slug cache-read sanitization.
|
||||||
|
*
|
||||||
|
* `eval "$(gstack-slug)"` is how callers load SLUG/BRANCH. The compute and
|
||||||
|
* fallback paths filter to [a-zA-Z0-9._-], but a value read straight from the
|
||||||
|
* cache file used to be echoed unsanitized — a planted cache file could inject
|
||||||
|
* shell. This pins the fix: a poisoned cache must never produce shell
|
||||||
|
* metacharacters in the SLUG= output line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { spawnSync } from 'bun';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
|
||||||
|
|
||||||
|
/** Reproduce the script's cache-key derivation: absolute path with / -> _. */
|
||||||
|
function cacheKeyFor(dir: string): string {
|
||||||
|
return dir.replace(/\//g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSlug(cwd: string, home: string) {
|
||||||
|
return spawnSync([SLUG_BIN], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, HOME: home },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gstack-slug cache-read sanitization', () => {
|
||||||
|
test('a poisoned cache file cannot inject shell metacharacters into output', () => {
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-home-'));
|
||||||
|
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-proj-'));
|
||||||
|
try {
|
||||||
|
const cacheDir = path.join(home, '.gstack', 'slug-cache');
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
// realpath: macOS tmpdir is a symlink (/var -> /private/var); the script
|
||||||
|
// runs in the resolved cwd, so key off the resolved path.
|
||||||
|
const realProj = fs.realpathSync(proj);
|
||||||
|
const payload = 'evil"; touch ' + path.join(home, 'pwned') + '; echo "x';
|
||||||
|
fs.writeFileSync(path.join(cacheDir, cacheKeyFor(realProj)), payload);
|
||||||
|
|
||||||
|
const out = runSlug(realProj, home);
|
||||||
|
const stdout = out.stdout.toString();
|
||||||
|
|
||||||
|
const slugLine = stdout.split('\n').find((l) => l.startsWith('SLUG='));
|
||||||
|
expect(slugLine).toBeDefined();
|
||||||
|
const slugValue = slugLine!.slice('SLUG='.length);
|
||||||
|
|
||||||
|
// The value must be sanitized: only [a-zA-Z0-9._-], no quotes/semicolons/spaces.
|
||||||
|
expect(slugValue).toMatch(/^[a-zA-Z0-9._-]*$/);
|
||||||
|
expect(slugLine).not.toContain('"');
|
||||||
|
expect(slugLine).not.toContain(';');
|
||||||
|
expect(slugLine).not.toContain(' ');
|
||||||
|
|
||||||
|
// And the injection must not have fired during the script's own run.
|
||||||
|
expect(fs.existsSync(path.join(home, 'pwned'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
|
fs.rmSync(proj, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
|
const DRIVER = path.join(ROOT, 'bin', 'gstack-jsonl-merge');
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-jsonl-merge-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the merge driver the way git does: `driver <base> <ours> <theirs>`.
|
||||||
|
* The driver writes the merged result back to the <ours> file. Returns that
|
||||||
|
* file's content. `base`/`ours`/`theirs` are arrays of JSONL lines (the file
|
||||||
|
* is created from them); pass `null` to omit a file entirely (git passes an
|
||||||
|
* absent path for an added file, which the driver must tolerate).
|
||||||
|
*/
|
||||||
|
function runMerge(
|
||||||
|
base: string[] | null,
|
||||||
|
ours: string[] | null,
|
||||||
|
theirs: string[] | null,
|
||||||
|
): string {
|
||||||
|
const write = (name: string, lines: string[] | null): string => {
|
||||||
|
const p = path.join(tmpDir, name);
|
||||||
|
if (lines === null) return path.join(tmpDir, `${name}.absent`);
|
||||||
|
fs.writeFileSync(p, lines.length ? lines.join('\n') + '\n' : '');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const basePath = write('base', base);
|
||||||
|
const oursPath = write('ours', ours);
|
||||||
|
const theirsPath = write('theirs', theirs);
|
||||||
|
execFileSync(DRIVER, [basePath, oursPath, theirsPath], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
return fs.readFileSync(oursPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gstack-jsonl-merge', () => {
|
||||||
|
test('equal-ts entries resolve identically regardless of side (convergence)', () => {
|
||||||
|
// Two machines append a different event in the same second, then each
|
||||||
|
// merges the other's push. Machine A sees its own line as "ours"; machine
|
||||||
|
// B sees the same line as "theirs". The merge must produce the same file
|
||||||
|
// on both, or the repos diverge and never reconcile.
|
||||||
|
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||||
|
const b = '{"ts":"2026-05-28T10:00:00Z","event":"b"}';
|
||||||
|
|
||||||
|
const machineA = runMerge([], [a], [b]); // a = ours, b = theirs
|
||||||
|
const machineB = runMerge([], [b], [a]); // b = ours, a = theirs
|
||||||
|
|
||||||
|
expect(machineA).toBe(machineB);
|
||||||
|
// Both lines survive.
|
||||||
|
expect(machineA).toContain('"event":"a"');
|
||||||
|
expect(machineA).toContain('"event":"b"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-timestamped lines also resolve identically regardless of side', () => {
|
||||||
|
const a = '{"event":"a"}'; // no ts -> hash-ordered
|
||||||
|
const b = '{"event":"b"}';
|
||||||
|
expect(runMerge([], [a], [b])).toBe(runMerge([], [b], [a]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain (non-JSON) lines resolve identically regardless of side', () => {
|
||||||
|
expect(runMerge([], ['zebra'], ['apple'])).toBe(
|
||||||
|
runMerge([], ['apple'], ['zebra']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact-duplicate lines are deduped', () => {
|
||||||
|
const line = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||||
|
const out = runMerge([line], [line], [line]);
|
||||||
|
expect(out.trimEnd().split('\n')).toEqual([line]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamped entries sort ascending by ts', () => {
|
||||||
|
const early = '{"ts":"2026-05-28T09:00:00Z","event":"early"}';
|
||||||
|
const late = '{"ts":"2026-05-28T11:00:00Z","event":"late"}';
|
||||||
|
const out = runMerge([], [late], [early]).trimEnd().split('\n');
|
||||||
|
expect(out).toEqual([early, late]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absent ours/theirs files are tolerated (added-file merge)', () => {
|
||||||
|
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
|
||||||
|
const out = runMerge(null, [a], null);
|
||||||
|
expect(out.trimEnd()).toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { resolveImportTimeoutMs } from "../bin/gstack-memory-ingest";
|
||||||
|
|
||||||
|
// #1611: the gbrain import timeout is configurable via GSTACK_INGEST_TIMEOUT_MS
|
||||||
|
// (default 30 min) so big-brain --full ingests aren't SIGTERM'd mid-import.
|
||||||
|
const DEFAULT = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
describe("resolveImportTimeoutMs", () => {
|
||||||
|
test("unset → 30 min default", () => {
|
||||||
|
expect(resolveImportTimeoutMs(undefined)).toBe(DEFAULT);
|
||||||
|
expect(resolveImportTimeoutMs("")).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid override is honored", () => {
|
||||||
|
expect(resolveImportTimeoutMs("3600000")).toBe(3_600_000); // 1h
|
||||||
|
expect(resolveImportTimeoutMs("60000")).toBe(60_000); // floor
|
||||||
|
expect(resolveImportTimeoutMs("86400000")).toBe(86_400_000); // ceiling
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid / out-of-range → default (no SIGTERM-too-soon footgun)", () => {
|
||||||
|
expect(resolveImportTimeoutMs("nope")).toBe(DEFAULT);
|
||||||
|
expect(resolveImportTimeoutMs("0")).toBe(DEFAULT);
|
||||||
|
expect(resolveImportTimeoutMs("59999")).toBe(DEFAULT); // below 1min floor
|
||||||
|
expect(resolveImportTimeoutMs("86400001")).toBe(DEFAULT); // above 24h ceiling
|
||||||
|
expect(resolveImportTimeoutMs("-5")).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -141,6 +141,35 @@ describe('taste-engine: Laplace-smoothed confidence', () => {
|
||||||
expect(rejected.rejected_count).toBe(1);
|
expect(rejected.rejected_count).toBe(1);
|
||||||
expect(rejected.approved_count).toBe(0);
|
expect(rejected.approved_count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repeated rejections raise rejected confidence toward 1', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
run(['rejected', `variant-${i}`, '--reason', 'fonts: Comic Sans']);
|
||||||
|
}
|
||||||
|
const p = readProfile();
|
||||||
|
const pref = p.dimensions.fonts.rejected[0];
|
||||||
|
expect(pref.rejected_count).toBe(5);
|
||||||
|
// Confidence reflects this bucket's signal: 5 / (5 + 0 + 1) = 0.833.
|
||||||
|
// Pre-fix this was approved_count/(total+1) = 0/6 = 0 for every rejected entry.
|
||||||
|
expect(pref.confidence).toBeCloseTo(5 / 6, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show ranks rejections by strength, not insertion order', () => {
|
||||||
|
run(['rejected', 'weak', '--reason', 'colors: beige']);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
run(['rejected', `strong-${i}`, '--reason', 'colors: crimson']);
|
||||||
|
}
|
||||||
|
const r = run(['show']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
// The strongly-rejected value must rank above the weakly-rejected one even
|
||||||
|
// though it was inserted second. Pre-fix both keys were 0, so the sort was a
|
||||||
|
// no-op and "beige" (inserted first) won.
|
||||||
|
const crimsonIdx = r.stdout.indexOf('crimson');
|
||||||
|
const beigeIdx = r.stdout.indexOf('beige');
|
||||||
|
expect(crimsonIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(beigeIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(crimsonIdx).toBeLessThan(beigeIdx);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('taste-engine: decay math', () => {
|
describe('taste-engine: decay math', () => {
|
||||||
|
|
@ -309,6 +338,19 @@ describe('taste-engine: taste drift conflict detection', () => {
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stderr).not.toContain('taste drift');
|
expect(r.stderr).not.toContain('taste drift');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('drift warning fires from real CLI rejections (no seeding)', () => {
|
||||||
|
// Build the opposite signal through the real CLI: 4 rejections take confidence
|
||||||
|
// to 4/5 = 0.8, above the 0.6 drift threshold. Pre-fix every rejected entry was
|
||||||
|
// pinned to confidence 0, so this branch was unreachable without hand-seeding.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
run(['rejected', `variant-${i}`, '--reason', 'fonts: Comic Sans']);
|
||||||
|
}
|
||||||
|
const r = run(['approved', 'v-approve', '--reason', 'fonts: Comic Sans']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toContain('taste drift');
|
||||||
|
expect(r.stderr).toContain('Comic Sans');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('taste-engine: migration', () => {
|
describe('taste-engine: migration', () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* Telemetry "no repo identity egress" invariant.
|
||||||
|
*
|
||||||
|
* The telemetry consent copy promises a user's repo name is recorded locally
|
||||||
|
* only and stripped before any upload (scripts/resolvers/preamble/
|
||||||
|
* generate-telemetry-prompt.ts). Two producers write repo/branch identity into
|
||||||
|
* the local skill-usage.jsonl:
|
||||||
|
*
|
||||||
|
* - the preamble epilogue → "repo"
|
||||||
|
* (scripts/resolvers/preamble/generate-preamble-bash.ts)
|
||||||
|
* - gstack-telemetry-log → "_repo_slug", "_branch"
|
||||||
|
* (bin/gstack-telemetry-log)
|
||||||
|
*
|
||||||
|
* gstack-telemetry-sync MUST strip every one of those fields before the remote
|
||||||
|
* POST (bin/gstack-telemetry-sync). This test enforces that contract three ways:
|
||||||
|
*
|
||||||
|
* 1. Coverage — every repo/branch field the producers emit is also stripped.
|
||||||
|
* Catches "added a new repo field, forgot to strip it" (the rename-to-_repo
|
||||||
|
* landmine, or any future producer drift).
|
||||||
|
* 2. Behavior — run the ACTUAL sed strip expressions from the sync script over
|
||||||
|
* a sample event line and assert no repo/branch field survives, while benign
|
||||||
|
* fields do. Catches a broken/edited regex, not just a missing line.
|
||||||
|
* 3. Floor — the three known fields are always in the stripped set, so deleting
|
||||||
|
* a strip rule fails CI even if a producer also stops emitting it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { spawnSync } from 'bun';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SYNC = path.join(ROOT, 'bin', 'gstack-telemetry-sync');
|
||||||
|
const PREAMBLE = path.join(ROOT, 'scripts', 'resolvers', 'preamble', 'generate-preamble-bash.ts');
|
||||||
|
const TEL_LOG = path.join(ROOT, 'bin', 'gstack-telemetry-log');
|
||||||
|
|
||||||
|
// Fields that identify the user's repo/branch. The promise is that NONE of
|
||||||
|
// these reach the network. Add to this floor if a new identity field is born.
|
||||||
|
const REPO_IDENTITY_FLOOR = ['repo', '_repo_slug', '_branch'];
|
||||||
|
|
||||||
|
const isRepoIdentity = (field: string) => /repo|branch/i.test(field);
|
||||||
|
|
||||||
|
/** Pull every `sed -e 's/.../g'` expression out of the sync script. */
|
||||||
|
function extractSedExprs(scriptText: string): string[] {
|
||||||
|
return [...scriptText.matchAll(/-e\s+'(s\/[^']*)'/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The JSON key a strip expression targets, e.g. `,"repo":"[^"]*"` -> `repo`. */
|
||||||
|
function fieldFromSedExpr(expr: string): string | null {
|
||||||
|
const m = expr.match(/,"([A-Za-z_][A-Za-z0-9_]*)":/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repo/branch JSON keys a producer writes INTO skill-usage.jsonl — the only
|
||||||
|
* file gstack-telemetry-sync reads and uploads. Scoped to the emission lines
|
||||||
|
* that target the synced file so local-only sinks (e.g. the timeline log, which
|
||||||
|
* carries "branch" but is never synced) don't count against the egress invariant.
|
||||||
|
*/
|
||||||
|
function emittedRepoFields(lines: string[]): string[] {
|
||||||
|
const text = lines.join('\n');
|
||||||
|
const keys = [...text.matchAll(/"([A-Za-z_][A-Za-z0-9_]*)":/g)].map((m) => m[1]);
|
||||||
|
return [...new Set(keys.filter(isRepoIdentity))];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('telemetry no-repo-identity-egress invariant', () => {
|
||||||
|
const syncText = fs.readFileSync(SYNC, 'utf-8');
|
||||||
|
const sedExprs = extractSedExprs(syncText);
|
||||||
|
const strippedRepoExprs = sedExprs.filter((e) => {
|
||||||
|
const f = fieldFromSedExpr(e);
|
||||||
|
return f !== null && isRepoIdentity(f);
|
||||||
|
});
|
||||||
|
const strippedFields = new Set(
|
||||||
|
strippedRepoExprs.map(fieldFromSedExpr).filter((f): f is string => f !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('floor: the three known repo-identity fields are stripped', () => {
|
||||||
|
for (const field of REPO_IDENTITY_FLOOR) {
|
||||||
|
expect(strippedFields.has(field)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped', () => {
|
||||||
|
// Only emission lines that target the synced file (skill-usage.jsonl). The
|
||||||
|
// preamble appends directly; gstack-telemetry-log builds the synced event
|
||||||
|
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
|
||||||
|
const preambleSynced = fs
|
||||||
|
.readFileSync(PREAMBLE, 'utf-8')
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.includes('skill-usage.jsonl'));
|
||||||
|
const telLogSynced = fs
|
||||||
|
.readFileSync(TEL_LOG, 'utf-8')
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
|
||||||
|
const emitted = new Set<string>([
|
||||||
|
...emittedRepoFields(preambleSynced),
|
||||||
|
...emittedRepoFields(telLogSynced),
|
||||||
|
]);
|
||||||
|
// The preamble must emit "repo" — guards against the test silently passing
|
||||||
|
// because a regex stopped matching the producer.
|
||||||
|
expect(emitted.has('repo')).toBe(true);
|
||||||
|
for (const field of emitted) {
|
||||||
|
expect(
|
||||||
|
strippedFields.has(field),
|
||||||
|
`producer emits repo-identity field "${field}" but gstack-telemetry-sync does not strip it (would leak to remote)`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('behavior: the real sed expressions remove repo identity, keep benign fields', () => {
|
||||||
|
const sample =
|
||||||
|
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
|
||||||
|
'"repo":"my-secret-repo","_repo_slug":"acme-my-secret-repo","_branch":"feature-x",' +
|
||||||
|
'"sessions":3,"installation_id":"abc123"}';
|
||||||
|
|
||||||
|
const sedArgs: string[] = [];
|
||||||
|
for (const e of strippedRepoExprs) {
|
||||||
|
sedArgs.push('-e', e);
|
||||||
|
}
|
||||||
|
const out = spawnSync(['sed', ...sedArgs], {
|
||||||
|
stdin: Buffer.from(sample),
|
||||||
|
});
|
||||||
|
const cleaned = out.stdout.toString();
|
||||||
|
|
||||||
|
// No repo/branch identity survives, value or key.
|
||||||
|
expect(cleaned).not.toContain('my-secret-repo');
|
||||||
|
expect(cleaned).not.toContain('feature-x');
|
||||||
|
expect(cleaned).not.toContain('"repo"');
|
||||||
|
expect(cleaned).not.toContain('_repo_slug');
|
||||||
|
expect(cleaned).not.toContain('_branch');
|
||||||
|
|
||||||
|
// Benign fields are untouched — the strip is surgical, not a blanket wipe.
|
||||||
|
expect(cleaned).toContain('"skill":"design-shotgun"');
|
||||||
|
expect(cleaned).toContain('"sessions":3');
|
||||||
|
expect(cleaned).toContain('"ts":"2026-06-02T00:00:00Z"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue