mirror of https://github.com/garrytan/gstack.git
Compare commits
10 Commits
a2e4405501
...
4540feab9d
| Author | SHA1 | Date |
|---|---|---|
|
|
4540feab9d | |
|
|
c43c850cae | |
|
|
3bef43bc5a | |
|
|
b88223677b | |
|
|
46c1fae7f1 | |
|
|
9562ad4e70 | |
|
|
dedfe42ef0 | |
|
|
62024d114c | |
|
|
070722ace3 | |
|
|
ce5fbfa99f |
|
|
@ -51,6 +51,15 @@ jobs:
|
||||||
if: matrix.os == 'ubicloud-standard-8'
|
if: matrix.os == 'ubicloud-standard-8'
|
||||||
run: sudo apt-get update && sudo apt-get install -y poppler-utils
|
run: sudo apt-get update && sudo apt-get install -y poppler-utils
|
||||||
|
|
||||||
|
# Install a color-emoji font BEFORE Chromium launches so the emoji render
|
||||||
|
# gate has a fallback font. macOS ships Apple Color Emoji already.
|
||||||
|
- name: Install color-emoji font (Ubuntu)
|
||||||
|
if: matrix.os == 'ubicloud-standard-8'
|
||||||
|
run: |
|
||||||
|
sudo apt-get install -y fonts-noto-color-emoji
|
||||||
|
fc-cache -f || true
|
||||||
|
fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' || true
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
- name: Install Playwright Chromium
|
||||||
run: bunx playwright install chromium
|
run: bunx playwright install chromium
|
||||||
|
|
||||||
|
|
@ -74,7 +83,7 @@ jobs:
|
||||||
- name: Run make-pdf unit tests
|
- name: Run make-pdf unit tests
|
||||||
run: bun test make-pdf/test/*.test.ts
|
run: bun test make-pdf/test/*.test.ts
|
||||||
|
|
||||||
- name: Run combined-features copy-paste gate (P0)
|
- name: Run E2E gates (combined-features copy-paste + emoji render)
|
||||||
env:
|
env:
|
||||||
BROWSE_BIN: ${{ github.workspace }}/browse/dist/browse
|
BROWSE_BIN: ${{ github.workspace }}/browse/dist/browse
|
||||||
run: bun test make-pdf/test/e2e/combined-gate.test.ts
|
run: bun test make-pdf/test/e2e/
|
||||||
|
|
|
||||||
403
CHANGELOG.md
403
CHANGELOG.md
|
|
@ -1,5 +1,385 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
## **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.**
|
||||||
|
|
||||||
|
`/ship` was a 167KB wall that every session paid for in full, whether you were bumping a version or writing a changelog or none of it. It is now a 69KB decision-tree skeleton plus eight `sections/*.md` files the agent opens on demand. The eight steps that are long prose (the test run, coverage audit, plan-completion, the review army, Greptile triage, the adversarial pass, the changelog, the PR body) moved into sections behind STOP-Read pointers, so a run only reads the chapters its situation calls for. The version-bump logic that used to be ~90 lines of inline bash, the single worst re-bump footgun in the workflow, is now the tested `gstack-version-bump` CLI (classify / write / repair). Other hosts (codex, factory, kiro, opencode) keep the full inline skill unchanged, so nothing regresses off Claude. This release dogfooded itself: the version you are reading was bumped by `gstack-version-bump`.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
Measured directly from the generated skill (`wc -c ship/SKILL.md`) and the new section files, regenerated for all hosts:
|
||||||
|
|
||||||
|
| Metric | Before (v1.53) | After (v1.54) | Δ |
|
||||||
|
|--------|----------------|---------------|---|
|
||||||
|
| ship always-loaded | 167 KB (~41.8K tokens) | 69 KB (~17.2K tokens) | -59% |
|
||||||
|
| ship prose loaded per run | all of it | only applicable sections | on-demand |
|
||||||
|
| ship version logic | ~90 lines inline bash | tested CLI, 15 unit tests | extracted |
|
||||||
|
| External-host ship | 167 KB inline | 162 KB inline (unchanged behavior) | no regression |
|
||||||
|
|
||||||
|
The skeleton is what loads the instant `/ship` is invoked, so the ~24.6K-token drop is paid back on every single ship, not just once.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
A `/ship` run starts ~3x lighter and pulls in each heavy step's instructions only when it reaches that step, so the agent spends less of its window holding prose it is not using yet. You will not notice any behavior change. The workflow is identical step for step; the difference is what is in context when. If you ever want to read a step in isolation, the chapters live at `~/.claude/skills/gstack/ship/sections/`.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- `bin/gstack-version-bump` — tested version-state CLI (classify / write / repair) with 15 unit tests covering the full FRESH / ALREADY_BUMPED / DRIFT_STALE_PKG / DRIFT_UNEXPECTED matrix.
|
||||||
|
- `ship/sections/*.md` — eight on-demand sections (tests, test-coverage, plan-completion, review-army, greptile, adversarial, changelog, pr-body) with a passive `manifest.json` registry.
|
||||||
|
- Section pipeline in `gen-skill-docs`: `{{SECTION:id}}` (STOP-Read pointer on Claude, inline on other hosts) and `{{SECTION_INDEX}}` (situation to section table rendered from the manifest).
|
||||||
|
- `test/helpers/transcript-section-logger.ts` + `required-reads.ts` and section-loading / manifest-consistency / context-parity tests guarding the carve.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- `/ship` is a skeleton + sections on Claude; external hosts still receive the full inline skill (no behavior change off Claude).
|
||||||
|
- Step 12 calls `gstack-version-bump` instead of inline bash.
|
||||||
|
- Parity harness understands carved skills (checks skeleton + sections union; asserts the skeleton actually shrank).
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
- `setup` links `sections/` into the prefixed Claude + Kiro skill dirs; `--host all` now fails the build on any host failure, not just claude.
|
||||||
|
- New section templates live at `<skill>/sections/*.md.tmpl`; regenerate with `bun run gen:skill-docs`.
|
||||||
|
## [1.53.1.0] - 2026-05-30
|
||||||
|
|
||||||
|
## **Workspace and scripted setup never hang on a hidden prompt again. Installing the plan-tune hooks is now flag-driven with safe defaults.**
|
||||||
|
|
||||||
|
`./setup` asked "Install both hooks now? [y/N]" with a blocking read. Run under a Conductor workspace or any forwarded terminal, that prompt had nobody to answer it, so setup hung forever. Now the decision comes from a flag, an env var, or saved config, and when nobody is there to answer it takes a safe default instead of waiting. A real terminal still gets the prompt, but it is time-bounded (auto-skips after 10s) so it can never stall a pipeline.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
- Spinning up a new workspace just works. `bin/dev-setup` runs fully non-interactively and never rewrites your global Claude settings behind your back.
|
||||||
|
- Want the plan-tune hooks installed without a prompt? `./setup --plan-tune-hooks` (or `GSTACK_PLAN_TUNE_HOOKS=yes`, or `gstack-config set plan_tune_hooks yes`). Don't want them? `--no-plan-tune-hooks`. Leave it unset and a real terminal still asks once, then remembers.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `--plan-tune-hooks` / `--no-plan-tune-hooks` / `--plan-tune-hooks=yes|no|prompt` flags on `./setup`, plus the `GSTACK_PLAN_TUNE_HOOKS` env var and a `plan_tune_hooks` config key (default `prompt`). Precedence: flag > env > saved config > prompt on a real terminal.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `./setup` no longer hangs in non-interactive or forwarded-TTY contexts (Conductor workspaces, CI). The plan-tune consent prompt is time-bounded and defaults to skip.
|
||||||
|
- `bin/dev-setup` runs setup non-interactively and can no longer silently rewrite your global `~/.claude/settings.json` to point at an ephemeral workspace path that breaks when the workspace is deleted.
|
||||||
|
- Opt-in values like `YES`, `Yes`, or ` yes` are honored instead of being silently downgraded to skip, and `gstack-config` now rejects out-of-domain `plan_tune_hooks` values.
|
||||||
|
|
||||||
|
### For contributors
|
||||||
|
|
||||||
|
- New regression suite `test/setup-plan-tune-hooks-noninteractive.test.ts` (flag wiring, no-blocking-read guard, decision normalization, config round-trip + domain rejection, dev-setup pin) with host-config isolation via a temp `GSTACK_HOME`.
|
||||||
|
- Rebaselined `test/parity-suite.test.ts` from the stale v1.44.1 anchor to v1.53.0.0. The 1.05 per-skill ratio is kept (only the anchor moved), absorbing legitimate v1.49–v1.53 planning-skill growth and clearing the 5 pre-existing parity failures noted in the v1.53.0.0 entry. Historical baselines retained for the v1→v2 audit trail.
|
||||||
|
- De-flaked `test/plan-tune.test.ts` "derive pushes scope_appetite up" (was ~25–50% flaky, worse on main): it now sets `GSTACK_QUESTION_LOG_NO_DERIVE=1` so gstack-question-log's fire-and-forget background `--derive` can't race the test's explicit one.
|
||||||
|
|
||||||
|
## [1.53.0.0] - 2026-05-29
|
||||||
|
|
||||||
|
## **Secrets, PII, and legal landmines get caught before they reach a public sink. One redaction engine now guards /spec, /ship, /cso, and the /document-* skills.**
|
||||||
|
|
||||||
|
`/spec` used to scan for seven secret patterns and only blocked the codex hand-off. Everything after that — the GitHub issue it filed, the local archive — went out unscanned. So you could pull an AWS key out of the draft, re-run, and still publish a customer's email to a world-readable issue. That gap is closed. A single shared engine (`lib/redact-patterns.ts` + `lib/redact-engine.ts`, driven by the new `gstack-redact` CLI) now scans the exact bytes that will be sent, at every sink: the codex dispatch, the issue body, the archive write, the PR body and title, and generated docs before they commit. HIGH-confidence credentials block. PII and legal/damaging content (a named person tied to "fired", a customer tied to "churn", NDA markers) prompt you per finding, with one-keystroke auto-redact for emails, phones, SSNs, and cards. Public repos get a sterner bar than private ones.
|
||||||
|
|
||||||
|
It is a guardrail, not a vault. `git push --no-verify`, a direct `gh issue create`, and `GSTACK_REDACT_PREPUSH=skip` all still get through. It catches accidents and carelessness, which is where real leaks come from.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
From the shipped engine and its test suite (`bun test test/redact-*.test.ts` and the per-skill wiring tests):
|
||||||
|
|
||||||
|
| Metric | Before (v1.52) | After (v1.53) | Δ |
|
||||||
|
|--------|----------------|---------------|---|
|
||||||
|
| Redaction patterns | 7 (secrets only) | 33 (secrets + PII + legal + internal) | +26 |
|
||||||
|
| Tiers | 1 (block) | 3 (block / confirm / FYI) | +2 |
|
||||||
|
| Enforcement sinks in /spec | 1 (codex only) | 3 (codex, issue, archive) | +2 |
|
||||||
|
| Skills guarded | 1 (/spec) | 5 (/spec, /ship, /cso, /document-release, /document-generate) | +4 |
|
||||||
|
| Redaction tests | ~5 string checks | 159 behavior tests | +154 |
|
||||||
|
|
||||||
|
Tier split of the 33 patterns: 17 HIGH (genuinely-secret credentials), 14 MEDIUM (PII, legal, internal-leak, plus high-FP credential shapes), 2 LOW. Calibration is the point: Stripe publishable keys, Google `AIza` keys, JWTs, and env-style `*_KEY=` sit at MEDIUM, not HIGH, because a gate that cries wolf gets muted.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
When you `/spec` or `/ship`, you no longer have to remember that the issue body is public. A real credential stops the operation cold and tells you to rotate it. An email or a sentence naming a coworker surfaces as a question, with auto-redact one keystroke away. Turn on the optional pre-push hook (`gstack-config set redact_prepush_hook true`) to catch the classic `.env`-into-the-diff push too. Nothing new to learn: it runs inside the skills you already use.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Shared redaction engine.** `lib/redact-patterns.ts` (33-pattern, 3-tier taxonomy — the single source of truth) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()` with Unicode normalization, ReDoS-safe size cap, Luhn/entropy/RFC1918 validators, safe-masked previews).
|
||||||
|
- **`gstack-redact` CLI** — scan stdin or a file, JSON or human output, exit 0/2/3 to gate skills, `--auto-redact` for the PII one-keystroke path, `--repo-visibility`, `--allowlist`, `--self-email`.
|
||||||
|
- **Opt-in pre-push hook** (`gstack-redact-prepush` + `gstack-redact install-prepush-hook`) — blocks a credential in the pushed diff (public and private), correct `remote..local` diff direction with new-branch/force-push/delete handling, chains any existing hook, `GSTACK_REDACT_PREPUSH=skip` escape valve.
|
||||||
|
- **`/spec` Phase 4.5a semantic review** — an in-conversation pass (no third party) for named-criticism, customer complaints, unannounced strategy, NDA material, and codename bleed, with a content-free audit trail at `~/.gstack/security/semantic-reviews.jsonl`.
|
||||||
|
- **Config keys** `redact_repo_visibility` (local-only override for repos `gh`/`glab` can't read) and `redact_prepush_hook`.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **`/spec`, `/ship`, `/document-release`, `/document-generate`** scan at every external sink, on the exact bytes sent (temp-file scan-at-sink, no scan-then-re-render gap). `/ship` wraps Codex/Greptile output in tool-attributed fences so the example credentials those tools quote degrade to a non-blocking warning instead of failing the PR.
|
||||||
|
- **`/cso`** shares the same canonical taxonomy via `lib/redact-patterns.ts` for its secrets archaeology.
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
- Skill docs for the redaction surface are generated from `scripts/resolvers/redact-doc.ts` (`{{REDACT_TAXONOMY_TABLE}}`, `{{REDACT_INVOCATION_BLOCK:<sink>}}`), so the five skills never drift from the engine.
|
||||||
|
- 12 new test files, 159 redaction assertions, plus a periodic-tier semantic-pass eval (`test/redact-semantic-pass.eval.ts`).
|
||||||
|
- Known pre-existing: the legacy `test/parity-suite.test.ts` (v1.44.1 baseline) reports 5 planning-skill size regressions inherited from the brain-aware-planning releases (v1.49–v1.52); they are unrelated to this branch and the active v1.47 size-budget gate passes. Tracked in TODOS.md to rebaseline.
|
||||||
|
|
||||||
|
## [1.52.2.0] - 2026-05-29
|
||||||
|
|
||||||
|
## **Emoji render in make-pdf PDFs on every platform. Linux stops printing tofu boxes, and setup installs the font for you.**
|
||||||
|
|
||||||
|
make-pdf used to render emoji code points as `.notdef` tofu (▯) on Linux. The cause was a missing fallback: the print CSS font stacks had no emoji family, and most Linux distros and containers ship no color-emoji font at all, so Skia drew empty boxes in every header and table that used emoji. Now the body and running-header stacks fall back through Apple Color Emoji, Segoe UI Emoji, and Noto Color Emoji, and `./setup` best-effort installs `fonts-noto-color-emoji` on Linux (apt, with dnf/pacman/apk fallbacks), refreshes the font cache, and restarts a running browser daemon so the next render picks it up. macOS and Windows already shipped an emoji font and are unchanged. Non-emoji Unicode (em dash, times, arrow, bullet, ellipsis) always worked and still does.
|
||||||
|
|
||||||
|
## The numbers that matter
|
||||||
|
|
||||||
|
Source: the emoji render gate, `bun test make-pdf/test/e2e/emoji-gate.test.ts`, rendering a fixture of color emoji at 100 dpi.
|
||||||
|
|
||||||
|
| Metric | Before | After | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Saturated (color) pixels in the rendered emoji region | ~0 (tofu) | ~1,650 | real color render |
|
||||||
|
| Platforms that render emoji correctly | macOS, Windows | macOS, Windows, Linux | +Linux |
|
||||||
|
| Emoji-bearing font stacks with a fallback family | 0 | 2 | body + running header |
|
||||||
|
| Deterministic render-proof gates | 0 | 1 | pdffonts + pixel |
|
||||||
|
|
||||||
|
A tofu box is a near-monochrome outline (close to zero colored pixels). A real emoji render lands about 1,650 saturated pixels. The gate asserts both that an emoji font embedded (`pdffonts`) and that the page actually rasterizes to color (`pdftoppm`), because PDF text extraction passes even when the glyph drew as tofu, so it cannot be trusted as the proof.
|
||||||
|
|
||||||
|
## What this means for builders
|
||||||
|
|
||||||
|
If you generate PDFs on Linux or inside a container, emoji in section headers and table status columns now render instead of ▯. Run `./setup` once on Linux to install the font; there is nothing to do on macOS or Windows. Set `GSTACK_SKIP_FONTS=1` to opt out on locked-down or offline machines.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- `ensure_emoji_font()` in `setup`: Linux color-emoji install across apt/dnf/pacman/apk, `fc-match` color-font detection (idempotent, skips when a real color font already resolves), `fc-cache` refresh under sudo, and a browse-daemon restart so a running render server sees the new font. Opt out with `GSTACK_SKIP_FONTS=1`. Non-interactive `sudo -n` and timeout-bound package calls so it never hangs setup.
|
||||||
|
- Emoji render gate (`make-pdf/test/e2e/emoji-gate.test.ts`) with a variation-selector (`❤️`, FE0F) fixture: asserts an emoji font embeds and the page rasterizes to color. Hard-fails in CI when poppler or the font is missing, so prerequisite drift can't hide a regression behind a green build.
|
||||||
|
- `resolvePopplerTool()` resolver for `pdffonts` / `pdfimages` / `pdftoppm`.
|
||||||
|
- The Ubuntu make-pdf CI gate installs `fonts-noto-color-emoji` before Chromium launches.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- Print CSS body and `@top-center` running-header font stacks fall back through Apple Color Emoji, Segoe UI Emoji, and Noto Color Emoji, placed before the generic `sans-serif`. All font stacks are now composed from shared constants.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- make-pdf no longer renders emoji as `.notdef` tofu (▯) on Linux.
|
||||||
|
## [1.52.1.0] - 2026-05-27
|
||||||
|
|
||||||
|
## **Brain-aware planning lands. Five planning skills read structured context from any personal gbrain before asking — same questions, smarter answers, no token tax.**
|
||||||
|
|
||||||
|
`/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, and `/plan-devex-review` now preflight a typed entity model from your gbrain (Wintermute, local PGLite, or any thin-client MCP) before their first AskUserQuestion. Reviews stop asking "what's the product?" / "who's the target user?" / "what was your prior scope call?" — that context loads from cached digests of typed `gstack/product`, `gstack/goal`, `gstack/developer-persona`, `gstack/brand`, `gstack/competitive-intel`, `gstack/skill-run`, `gstack/user-profile`, and `gstack/take` pages. The brain becomes a structured model of your product and your judgment patterns, not just a search index.
|
||||||
|
|
||||||
|
The unlock: every planning skill filters its recommendations through "what does the user actually want right now, what is this product, what have we decided before." That's the qualitative shift codex outside-voice argued for — the brain telling reviews "this contradicts your January CEO plan" or "your developer persona digest says first-time CLI users; this plan adds 3 setup commands."
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
Source: `bun test test/brain-cache-spec.test.ts test/skill-preflight-budget.test.ts` (verifies budgets statically) and `bin/gstack-brain-cache get product` smoke (verifies warm-hit latency).
|
||||||
|
|
||||||
|
| Surface | Before | After | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Planning-skill cold-start tokens (preflight context) | 0 (asked everything) | 500–1500 tokens (warm hit) / 5–15 KB once-per-day (cold miss) | brain-as-model, not just search |
|
||||||
|
| MCP calls per skill invocation (warm hit) | n/a (no integration) | 0 (single disk read) | 95% path |
|
||||||
|
| MCP calls per skill invocation (cold miss) | n/a | 4–8 parallel calls, ~1–2s once | bounded |
|
||||||
|
| Autoplan (4 sequential skills) preflight cost | n/a | 1 cold-miss + 3 warm-hits via lockfile dedup | concurrent dedup saves 4× |
|
||||||
|
| New typed brain page kinds | 0 | 8 (`gstack-core@1.0.0` schema pack) | first-class entity model |
|
||||||
|
| Per-endpoint trust policies | 0 (sync mode global only) | 1 per `sha8(MCP URL)` namespace, hash collision → sha16 | shared-brain safe |
|
||||||
|
| New gate-tier tests | 0 | 10 files / 111 assertions | every correctness path covered |
|
||||||
|
|
||||||
|
The cache layer keeps the brain integration honest: 95% of invocations are a single disk read at ~10–30ms; cold-miss pays a one-time ~1–2s tax that's deduplicated across concurrent autoplan dispatches via a project-scoped lockfile. Salience is filtered by an allowlist (`projects/`, `concepts/`, `gstack/`) before write so personal pages — family, therapy, reflection — never leak into work-flow planning prompts. The trust-policy primitive makes personal-brain auto-push safe and shared-brain reads conservative by default.
|
||||||
|
|
||||||
|
### What this means for you
|
||||||
|
|
||||||
|
If you use planning skills today: every invocation gets sharper without you doing anything different. The skills ask fewer redundant questions and surface "this contradicts your Jan plan" / "your Feb TTHW benchmark was 2:15 vs the 5:30 baseline" / "tendency to under-expand on infra plans" — the brain doing the bookkeeping that your memory shouldn't have to.
|
||||||
|
|
||||||
|
If you use a remote MCP brain (Wintermute or your own): `/setup-gbrain` Step 9.5 asks the trust-policy question once per endpoint. Personal endpoint → `~/.gstack/` artifacts auto-push and calibration takes write back to your brain. Shared/team endpoint → reads only, prompts before writes, user-namespaced via federation sources or `users/<slug>/gstack/` prefix.
|
||||||
|
|
||||||
|
If you use local PGLite: auto-detected as personal; no question fires. The cache lives at `~/.gstack/{,projects/<slug>/}brain-cache/` with per-entity TTLs.
|
||||||
|
|
||||||
|
If you're a contributor: the new resolver pattern (`{{BRAIN_PREFLIGHT}}` / `{{BRAIN_CACHE_REFRESH}}` / `{{BRAIN_WRITE_BACK}}`) is the template seam for the brain integration. Empty string for any skill not in `SKILL_DIGEST_SUBSETS` — drop the placeholders anywhere with zero cost.
|
||||||
|
|
||||||
|
Phase 2 calibration write-back is gated behind the `BRAIN_CALIBRATION_WRITEBACK` feature flag (default off) until upstream gbrain ships `takes_add` / `takes_resolve` MCP ops (filed in TODOS.md as P2). When the flag flips, the existing skill templates pick up the write-back behavior with no template changes.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- `scripts/brain-cache-spec.ts` — single source of truth for `BRAIN_CACHE_ENTITIES` (8 entities × TTL + budget + invalidation rules), `SKILL_DIGEST_SUBSETS` (per-skill which files to load), `SALIENCE_DEFAULT_ALLOWLIST`, `SKILL_CALIBRATION_WEIGHTS`, trust-policy + schema-pack constants.
|
||||||
|
- `scripts/gstack-schema-pack.ts` — `gstack-core@1.0.0` schema pack with 8 typed page kinds: `user-profile`, `product`, `goal`, `developer-persona`, `brand`, `competitive-intel`, `skill-run`, `take`. Frontmatter shapes, retention policies, link verbs for `mcp__gbrain__schema_graph`.
|
||||||
|
- `bin/gstack-brain-cache` — three-tier cache CLI: `get` / `refresh` / `invalidate` / `digest` / `meta` / `bootstrap` / `list` / `purge` subcommands. Atomic writes, TTL staleness, schema-version full-rebuild on mismatch, stale-but-usable fallback, concurrent-refresh lockfile dedup.
|
||||||
|
- `scripts/resolvers/gbrain.ts` — three new resolver functions: `generateBrainPreflight`, `generateBrainCacheRefresh`, `generateBrainWriteBack`. Empty-string for non-preflight skills (defensive).
|
||||||
|
- `bin/gstack-config` — `brain_trust_policy@<endpoint-hash>` namespace, `endpoint-hash` subcommand (sha8 with collision → sha16 escalation), `resolve-user-slug` subcommand (D4 A3 identity resolution chain: `whoami` → `$USER` → `sha8(git email)` → `anonymous-<sha8(hostname)>`).
|
||||||
|
- `setup-gbrain` Step 9.5 — brain trust policy question per-endpoint. Local auto-set personal; remote-ambiguous asks; personal flips `artifacts_sync_mode=full`.
|
||||||
|
- `sync-gbrain` — `--refresh-cache` flag (replaces planned `/brain-refresh-context` skill per D1 fold), `--audit` flag (gstack-owned page summary + salience leak check), Step 1 trust-policy gate.
|
||||||
|
- 10 new gate-tier test files (111 assertions): `brain-cache-spec`, `gstack-schema-pack`, `brain-cache-roundtrip`, `cache-concurrent-refresh`, `salience-allowlist`, `brain-preflight`, `user-slug-fallback`, `schema-version-migration`, `takes-fence-fallback`, `skill-preflight-budget`.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- 5 planning SKILL.md.tmpl files wired with `{{BRAIN_PREFLIGHT}}` (top of skill body) and `{{BRAIN_CACHE_REFRESH}}` / `{{BRAIN_WRITE_BACK}}` (end of skill) placeholders.
|
||||||
|
- `scripts/resolvers/index.ts` registers `BRAIN_PREFLIGHT`, `BRAIN_CACHE_REFRESH`, `BRAIN_WRITE_BACK`.
|
||||||
|
|
||||||
|
**For contributors**
|
||||||
|
- Three follow-ups deferred to `TODOS.md` (P2 / P3): `/gstack-reflect` nightly synthesis, cross-machine brain-cache sync, dedicated `/gstack-onboarding` skill.
|
||||||
|
- Upstream gbrain dependency for Phase 2: `takes_add` + `takes_resolve` MCP ops in `~/git/gbrain/` (filed as P2 in TODOS.md). Phase 2 wiring already exists behind `BRAIN_CALIBRATION_WRITEBACK` flag; flag flips when upstream lands.
|
||||||
|
- Plan / CEO + eng review record: `~/.claude/plans/hm-interesting-well-why-dapper-eagle.md` (Approach B + 5 cherry-picks + 11 D-decisions from full eng review + codex outside-voice synthesis).
|
||||||
|
|
||||||
|
### Save-results path: works under any CLI when gbrain is on PATH
|
||||||
|
|
||||||
|
Brain-aware planning saves the actual review document to gbrain, not just preflight digests and calibration takes. Setup detects gbrain at install time and, if present, the planning skills emit compressed `gbrain put "<prefix>/<feature-slug>"` instructions for `office-hours/`, `ceo-plans/`, `eng-reviews/`, `design-reviews/`, and `devex-reviews/` slug spaces. If gbrain is not detected, the save-results block is suppressed entirely. Zero token overhead for users without gbrain. If you install gbrain after running `./setup`, run `gstack-config gbrain-refresh` to pick up the change.
|
||||||
|
|
||||||
|
Token cost stays tight: the inline save-results block is ~150 tokens per planning skill (down from ~1000 a naive un-suppression would have added). The full save template (heredoc body, entity-stub instructions, throttle handling, backlinks) lives in `docs/gbrain-write-surfaces.md` §Save Template and the agent reads it on demand only when it actually saves. Same compression discipline for the brain-context-load block: ~115 tokens with skip-header pointing to §Context Load.
|
||||||
|
|
||||||
|
| Detection state | Per-planning-skill token overhead | What the agent does on save |
|
||||||
|
|---|---|---|
|
||||||
|
| gbrain on PATH + `gstack-config gbrain-refresh` says `local_status: "ok"` | ~250 tokens (CONTEXT_LOAD + SAVE_RESULTS, compressed) | reads `docs/gbrain-write-surfaces.md` on demand, calls `gbrain put <prefix>/<slug>` |
|
||||||
|
| gbrain not on PATH | 0 tokens | block suppressed at gen-time, nothing rendered |
|
||||||
|
| GBrain or Hermes host adapter | full inline render (unchanged) | calls `gbrain put` always |
|
||||||
|
|
||||||
|
Wired for all five planning skills uniformly: `office-hours`, `plan-ceo-review`, `plan-eng-review`, `plan-design-review`, `plan-devex-review`. The last two gained the `{{GBRAIN_SAVE_RESULTS}}` placeholder in their templates (previously only the first three had it, so design-review and devex-review produced no retrievable page even under GBrain CLI).
|
||||||
|
|
||||||
|
Coverage: a free resolver-level unit test pins per-skill slug + tag metadata + the compressed token budget (`test/resolvers-gbrain-save-results.test.ts`, 10 tests / 53 assertions); a free override-mechanism test asserts the detection file gates resolver rendering correctly across `detected: true`, `detected: false`, and `no file` states (`test/gbrain-detection-override.test.ts`, 4 tests); a periodic-tier fake-CLI E2E drives `/office-hours` against a stub `gbrain` on PATH and asserts the agent actually calls `gbrain put office-hours/<slug>` with valid YAML frontmatter (`test/skill-e2e-office-hours-brain-writeback.test.ts`, ~$0.50-1/run); a periodic-tier real-CLI round-trip drives `gbrain init --pglite` + `gbrain put` + `gbrain get` against an isolated temp HOME and asserts the body survives (`test/skill-e2e-gbrain-roundtrip-local.test.ts`, ~$0.001/run, skips if `VOYAGE_API_KEY` is unset). Together: the agent obeys the resolver instruction, the resolver emits a valid CLI shape, and the CLI persists the page on the local engine. Remote/Supabase routing is gbrain's contract to honor — the same CLI shape covers all engines, so gstack stops at local round-trip coverage.
|
||||||
|
|
||||||
|
**For contributors (save-results layer):**
|
||||||
|
- `bin/gstack-config gbrain-refresh` re-runs `bin/gstack-gbrain-detect` and writes `~/.gstack/gbrain-detection.json`. `./setup` runs this at the end of install and conditionally regenerates Claude-host SKILL.md with `bun run gen:skill-docs:user` (added package.json script) so detected installs get the brain blocks immediately.
|
||||||
|
- The default `bun run gen:skill-docs` (CI canonical) ignores the detection file. Committed SKILL.md stays reproducible regardless of any developer's local gbrain state. Use `bun run gen:skill-docs:user` for user-local installs.
|
||||||
|
- Two follow-ups deferred to `TODOS.md` (P2): re-verify calibration takes when gbrain v0.42+ ships `takes_add` (the `BRAIN_CALIBRATION_WRITEBACK` flag flips); extend the brain-writeback E2E to the other 4 planning skills.
|
||||||
|
|
||||||
|
## [1.52.0.0] - 2026-05-27
|
||||||
|
|
||||||
|
## **`/plan-tune` settings actually do something now. Hooks make capture deterministic, preferences binding, and free-text answers loop back as memory.**
|
||||||
|
|
||||||
|
Before this release, plan-tune was a profile inspector with a hollow substrate. Every gstack skill told the agent "log this AskUserQuestion fire," and in weeks of dogfood, zero events ever landed. Preferences were agent-honored convention. Declared profile dimensions sat in a JSON file doing nothing. After this release: a PostToolUse hook captures every AUQ fire whether the agent remembers to log or not. A PreToolUse hook substitutes auto-decided answers when you've set `never-ask`. Free-text "Other" responses get dream-cycled through Claude into structured proposals you approve, then injected into future related questions as inline context. Codex sessions are backfilled by a structured-JSONL parser, not regex on transcript text.
|
||||||
|
|
||||||
|
The cathedral lands behind one explicit consent prompt at `./setup` (with diff preview, backup, and one-command rollback) and stays on once installed.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
Measured against the existing v1.49 substrate. Reproduce with `bun test test/plan-tune-gates.test.ts test/question-log-hook.test.ts test/question-preference-hook.test.ts test/memory-cache-injection.test.ts test/distill-free-text.test.ts test/distill-apply.test.ts test/declared-annotation.test.ts test/gstack-codex-session-import.test.ts test/skill-e2e-plan-tune-cathedral.test.ts`.
|
||||||
|
|
||||||
|
| Metric | Before (v1.49.0.0) | After (v1.52.0.0) | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AUQ events captured per session | 0 (agent convention) | every fire (hook) | substrate works |
|
||||||
|
| `never-ask` preferences enforced | 0% (agent convention) | 100% (hook + deny+reason) | actually binds |
|
||||||
|
| Declared profile annotations | 0 / week | every signal_key match | profile renders |
|
||||||
|
| Dream-cycle memory persistence | 0 (no mechanism) | per-project + gbrain mirror | cross-project recall |
|
||||||
|
| Codex session backfill | none (regex idea) | structured JSONL parser | future-proof |
|
||||||
|
| Per-PR test cost added | $0 | $0 (deterministic; no claude -p) | gate-tier safe |
|
||||||
|
| Unit + E2E tests added | — | 96 tests / 8 new files | green |
|
||||||
|
|
||||||
|
| Layer | What it does | Where it lives |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 — Capture | PostToolUse hook → question-log.jsonl with dedup + async derive | hosts/claude/hooks/question-log-hook.ts |
|
||||||
|
| 2 — Enforcement | PreToolUse hook → deny+reason with auto-decided option | hosts/claude/hooks/question-preference-hook.ts |
|
||||||
|
| 3 — Annotation | declared profile → kebab signal_key → plain-English phrase | scripts/declared-annotation.ts |
|
||||||
|
| 4 — Surfaces | host-aware Stats, Recent auto-decisions, Audit unmarked | plan-tune/SKILL.md.tmpl |
|
||||||
|
| 5 — Discoverability | setup hook-install prompt + post-ship nudge | setup, ship/SKILL.md.tmpl |
|
||||||
|
| 6 — Tests | 5 E2E scenarios, all gate tier, $0 cost | test/skill-e2e-plan-tune-cathedral.test.ts |
|
||||||
|
| 7 — Installation | schema-aware bin: PreToolUse + PostToolUse, backup + rollback | bin/gstack-settings-hook |
|
||||||
|
| 8 — Dream cycle | Anthropic SDK distill + gbrain put_page + memory injection | bin/gstack-distill-* + Layer 2 inject |
|
||||||
|
|
||||||
|
Highest-impact number is the third row: declared profile annotations now render inline before every AUQ that matches a signal_key. Set `declared.scope_appetite = 0.85` once during /plan-tune setup, and every "should I bundle this fix?" question shows up with "(your profile leans complete-implementation)" on the recommended option. The same loop applies to verbose-vs-terse, consult-vs-delegate, and ship-now-vs-get-the-design-right.
|
||||||
|
|
||||||
|
### What this means for solo builders
|
||||||
|
|
||||||
|
The feature compounds now. Each AskUserQuestion you answer "Other" with free text gets captured by the hook, batched into proposals by `gstack-distill-free-text` (3/day cap, ~$0.01 per run), reviewed via `/plan-tune distill`, and applied as either a `never-ask` preference, a declared-profile nudge, or a reusable memory nugget that routes to your gbrain (when configured) and reappears as context the next time a related question fires. The dream cycle is the unlock — without it, every nuanced answer evaporated after one turn. Now they accumulate. Run `./setup` and accept the hook-install prompt to turn it on, then `/plan-tune` whenever you want to see what your profile knows about you.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- `hosts/claude/hooks/question-log-hook` — PostToolUse hook, matcher covers `AskUserQuestion` + `mcp__*__AskUserQuestion`. Captures every AUQ fire with marker-first question_id (D18), hash-fallback observed-only, source-tagged.
|
||||||
|
- `hosts/claude/hooks/question-preference-hook` — PreToolUse hook with `(recommended)`-label parser, refuse-on-ambiguous (D2 safety), project-then-global preference precedence (D8), one-way safety override. Auto-decided events logged from the hook itself since deny prevents PostToolUse from firing.
|
||||||
|
- `scripts/declared-annotation.ts` — `getDeclaredAnnotation(signal_key)` with kebab→underscore namespace mapping. Returns null in the middle band, plain-English phrase in strong bands (>= 0.7 or <= 0.3).
|
||||||
|
- `bin/gstack-codex-session-import` — structured JSONL parser for `~/.codex/sessions/`. Marker-first recovery with pattern fallback, source-tagged `codex-import-marker` / `codex-import-pattern`.
|
||||||
|
- `bin/gstack-distill-free-text` — Layer 8 dream cycle distiller. Anthropic SDK direct call (Haiku 4.5), 3/day rate cap per slug (D7), cumulative cost log, sync-or-background execution context (D14).
|
||||||
|
- `bin/gstack-distill-apply` — applies one approved proposal to its surface (preference / declared-nudge / memory-nugget), with optional `--gbrain-published true` flag.
|
||||||
|
- `setup` — interactive consent prompt for hook installation with diff preview, backup, one-command rollback. Marker-gated so users are asked at most once.
|
||||||
|
- `ship/SKILL.md.tmpl` Step 21 — post-success plan-tune nudge, marker-gated for at-most-once.
|
||||||
|
- `docs/spikes/claude-code-hook-mutation.md` + `docs/spikes/codex-session-format.md` — Phase 1 spike outputs that pinned protocol contracts before implementation.
|
||||||
|
- 96 new tests across 8 files: STATE_ROOT honoring, v1.49 gates, settings-hook schema-aware ops, both hooks, declared-annotation, codex import, distill bin, distill apply, memory injection, 5 cathedral E2E scenarios.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- `bin/gstack-settings-hook` schema-aware rewrite: PreToolUse + PostToolUse registration with `_gstack_source` tag for dedup, `add-event` / `remove-source` / `diff-event` / `rollback` / `list-sources` subcommands. Legacy `add`/`remove` SessionStart shape preserved verbatim.
|
||||||
|
- `bin/gstack-question-log` — accepts source, tool_use_id, free_text; composite dedup on (source, tool_use_id) across last 100 lines (D3); async-fires `gstack-developer-profile --derive` after every successful write (D17 — without this, sample_size stayed 0).
|
||||||
|
- Three bins (`gstack-question-log`, `gstack-question-preference`, `gstack-developer-profile`) + `gstack-config` now honor `GSTACK_STATE_ROOT` env var as highest-priority override (D16 Codex correction — without this, isolation tests silently wrote to real ~/.gstack).
|
||||||
|
- `scripts/resolvers/question-tuning.ts` preamble — added marker-embedding convention (`<gstack-qid:{id}>`) and `(recommended)` label convention. Hook enforcement gates on marker presence.
|
||||||
|
- `scripts/question-registry.ts` — added `signal_key: 'decision-autonomy'` to `land-and-deploy-merge-confirm` and `land-and-deploy-rollback` so the autonomy dimension has a real signal source.
|
||||||
|
- `scripts/psychographic-signals.ts` — added `decision-autonomy` signal map.
|
||||||
|
- `plan-tune/SKILL.md.tmpl` — new sections (Recent auto-decisions, Audit unmarked, Dream cycle review, Dream cycle distill); host-aware Stats with source breakdown + MARKED %; Step 0 routing extended with dream-cycle gate.
|
||||||
|
- `bin/gstack-uninstall` — also cleans up `plan-tune-cathedral`-tagged hooks during uninstall.
|
||||||
|
|
||||||
|
**For contributors**
|
||||||
|
- 4 cross-model tension resolutions during eng review locked in: project preferences win over global (D8), hash IDs are observed-only never preference keys (D18), AUQ matcher covers MCP variants (Codex correction), enforcement uses `permissionDecision: "deny"` + reason instead of `"allow"` + `updatedInput` until the AUQ input shape is verified against real Claude Code (T6 conservative path).
|
||||||
|
- Plan-review preamble byte budget ratcheted 39000 → 40000 in `test/gen-skill-docs.test.ts` (~700 bytes added by the marker convention).
|
||||||
|
- 9 Codex outside-voice findings folded directly without re-prompting (matcher correction, derive wiring, settings.json consent, signal_key namespace, etc.).
|
||||||
|
|
||||||
## [1.51.0.0] - 2026-05-27
|
## [1.51.0.0] - 2026-05-27
|
||||||
|
|
||||||
## **Long-running browser sessions hold flat RSS on the Bun side. `$B memory` gives every future OOM receipts instead of a screenshot.** Four CDP-resource leak classes closed and pinned with tripwires; a structured diagnostic surfaces Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes in real time.
|
## **Long-running browser sessions hold flat RSS on the Bun side. `$B memory` gives every future OOM receipts instead of a screenshot.** Four CDP-resource leak classes closed and pinned with tripwires; a structured diagnostic surfaces Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes in real time.
|
||||||
|
|
@ -53,6 +433,29 @@ The next time you leave a gbrowser session running for days, the Bun side holds
|
||||||
- Coverage audit: 44% pre-diagnostic-tests → ~62% after adding the formatter coverage. Strong paths (CDP session lifecycle, body materialization, history cap, tab guardrail, SSE cleanup) all at 100% with invariant tests. Extension UI tests deferred (no extension test harness in this repo today).
|
- Coverage audit: 44% pre-diagnostic-tests → ~62% after adding the formatter coverage. Strong paths (CDP session lifecycle, body materialization, history cap, tab guardrail, SSE cleanup) all at 100% with invariant tests. Extension UI tests deferred (no extension test harness in this repo today).
|
||||||
- The CDP-session cleanup tripwire is the most reusable artifact here — any future addition of CDP work should route through the two helpers. Trying to call `newCDPSession` outside `cdp-bridge.ts` fails CI immediately with a pointer to the right helper.
|
- The CDP-session cleanup tripwire is the most reusable artifact here — any future addition of CDP work should route through the two helpers. Trying to call `newCDPSession` outside `cdp-bridge.ts` fails CI immediately with a pointer to the right helper.
|
||||||
|
|
||||||
|
## [1.49.0.0] - 2026-05-26
|
||||||
|
|
||||||
|
## **`/plan-tune` learns to ask for consent before logging, and runs the 5-question setup automatically when your profile is empty.**
|
||||||
|
|
||||||
|
Run `/plan-tune` the first time and you get an opt-in prompt. Accept and the 5-question wizard fills in your declared profile in about two minutes. Decline and `/plan-tune` never asks again. Contributors see a slightly different prompt explaining that local question-log data helps gstack calibrate, but the default is the same: off until you say yes.
|
||||||
|
|
||||||
|
If you already opted in via `gstack-config set question_tuning true` and skipped the wizard, the next `/plan-tune` runs just the 5-question setup so your profile actually has values.
|
||||||
|
|
||||||
|
Both flows write marker files in `~/.gstack/` so you're asked at most once per choice.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- `/plan-tune` consent prompt with contributor-specific copy. Honored by `~/.gstack/.question-tuning-prompted` marker.
|
||||||
|
- `/plan-tune` setup gate. Catches `question_tuning: true` with empty `declared`. Honored by `~/.gstack/.declared-setup-prompted` marker.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- `TODOS.md` E1 dependency line aligned with the canonical 90-day gate in `docs/designs/PLAN_TUNING_V0.md`. The 7-day diversity gate is for displaying inferred values in `/plan-tune` output; the 90-day gate is for shipping behavior adaptation. Both gates documented inline in `plan-tune/SKILL.md.tmpl`.
|
||||||
|
- `TODOS.md` E1 substrate constraint: E1 adaptations land as advisory annotations on AskUserQuestion recommendations, not as runtime AUTO_DECIDE on inferred profile alone.
|
||||||
|
|
||||||
|
**For contributors**
|
||||||
|
- `plan-tune/SKILL.md` size budget override (50,123 → 52,963 bytes, ×1.06 vs v1.44.1 baseline). Reason logged to audit trail.
|
||||||
|
|
||||||
## [1.48.0.0] - 2026-05-26
|
## [1.48.0.0] - 2026-05-26
|
||||||
|
|
||||||
## **Agents stop dropping AskUserQuestion options when there are 5+.** A new canonical preamble rule + runtime gate makes Conductor's 4-option cap a split-or-batch decision, not a silent trim.
|
## **Agents stop dropping AskUserQuestion options when there are 5+.** A new canonical preamble rule + runtime gate makes Conductor's 4-option cap a split-or-batch decision, not a silent trim.
|
||||||
|
|
|
||||||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -418,6 +418,44 @@ because they're tracked despite `.gitignore` — ignore them. When staging files
|
||||||
always use specific filenames (`git add file1 file2`) — never `git add .` or
|
always use specific filenames (`git add file1 file2`) — never `git add .` or
|
||||||
`git add -A`, which will accidentally include the binaries.
|
`git add -A`, which will accidentally include the binaries.
|
||||||
|
|
||||||
|
## Redaction guard (PII / secrets / legal content)
|
||||||
|
|
||||||
|
Shared redaction engine catches credentials, PII, and legal/damaging content
|
||||||
|
before it reaches an external sink (codex dispatch, GitHub issue/PR body, pushed
|
||||||
|
commit). It is a **guardrail, not airtight enforcement** — `git push --no-verify`,
|
||||||
|
direct `gh issue create`, and `GSTACK_REDACT_PREPUSH=skip` all bypass it. It
|
||||||
|
catches accidents and carelessness, the 99% case. Do not claim it stops a
|
||||||
|
determined leaker (a CHANGELOG line that does would fail a hostile screenshotter).
|
||||||
|
|
||||||
|
- **Engine + taxonomy:** `lib/redact-patterns.ts` (the single source of truth —
|
||||||
|
3 tiers; HIGH = genuinely-secret credentials that block, MEDIUM = PII/legal/
|
||||||
|
internal + high-FP credential shapes that confirm via AskUserQuestion, LOW =
|
||||||
|
FYI) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()`).
|
||||||
|
Calibration matters: a gate that cries wolf gets ignored, so context-variable
|
||||||
|
shapes (Stripe `pk_live_`, Google `AIza`, JWT, env `*_KEY=`) sit at MEDIUM.
|
||||||
|
- **CLI:** `bin/gstack-redact` (exit 0 clean / 2 MEDIUM / 3 HIGH; `--json`,
|
||||||
|
`--auto-redact`, `--repo-visibility`, `--from-file`). `bin/gstack-redact-prepush`
|
||||||
|
is the opt-in git hook.
|
||||||
|
- **Skill docs are generated** from `scripts/resolvers/redact-doc.ts`
|
||||||
|
(`{{REDACT_TAXONOMY_TABLE}}`, `{{REDACT_INVOCATION_BLOCK:<sink>}}`) so /spec,
|
||||||
|
/cso, /ship, /document-release, /document-generate never drift from the engine.
|
||||||
|
- **Scan-at-sink:** always scan the EXACT bytes that will be sent — write to a
|
||||||
|
temp file, scan that file, pass the SAME file to `gh`/`git`. Never scan a string
|
||||||
|
then re-render (that reopens a scan-vs-send gap).
|
||||||
|
- **Visibility (no tier promotion):** resolve once per run, order = local config
|
||||||
|
(`gstack-config get redact_repo_visibility`, ~/.gstack so never committed) → gh
|
||||||
|
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
|
||||||
|
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
|
||||||
|
auto-promoted to HIGH.
|
||||||
|
- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
|
||||||
|
/ ` ```greptile ` fences so example credentials those tools quote WARN-degrade
|
||||||
|
instead of blocking. A live-format credential inside the fence still blocks.
|
||||||
|
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
|
||||||
|
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
|
||||||
|
There is intentionally NO key to disable HIGH blocking.
|
||||||
|
- **Audit:** the /spec semantic pass appends a content-free record (categories +
|
||||||
|
body sha256, no spec text) to `~/.gstack/security/semantic-reviews.jsonl` (0600).
|
||||||
|
|
||||||
## Commit style
|
## Commit style
|
||||||
|
|
||||||
**Always bisect commits.** Every commit should be a single logical change. When
|
**Always bisect commits.** Every commit should be a single logical change. When
|
||||||
|
|
@ -900,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 -->
|
||||||
|
|
|
||||||
|
|
@ -326,11 +326,13 @@ If you're using [Conductor](https://conductor.build) to run multiple Claude Code
|
||||||
|
|
||||||
| Hook | Script | What it does |
|
| Hook | Script | What it does |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `setup` | `bin/dev-setup` | Copies `.env` from main worktree, installs deps, symlinks skills |
|
| `setup` | `bin/dev-setup` | Copies `.env` from main worktree, installs deps, symlinks skills, runs `./setup` non-interactively |
|
||||||
| `archive` | `bin/dev-teardown` | Removes skill symlinks, cleans up `.claude/` directory |
|
| `archive` | `bin/dev-teardown` | Removes skill symlinks, cleans up `.claude/` directory |
|
||||||
|
|
||||||
When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It detects the main worktree (via `git worktree list`), copies your `.env` so API keys carry over, and sets up dev mode — no manual steps needed.
|
When Conductor creates a new workspace, `bin/dev-setup` runs automatically. It detects the main worktree (via `git worktree list`), copies your `.env` so API keys carry over, and sets up dev mode — no manual steps needed.
|
||||||
|
|
||||||
|
`bin/dev-setup` runs `./setup` fully non-interactively (it passes `--plan-tune-hooks=prompt` and closes stdin), so a forwarded Conductor TTY can never hang on a hidden setup prompt. It also never installs the plan-tune Claude Code hooks, which means a throwaway workspace can't rewrite your global `~/.claude/settings.json` to point at an ephemeral worktree path. To install the plan-tune hooks deliberately, run `./setup --plan-tune-hooks` outside dev-setup (or `gstack-config set plan_tune_hooks yes`).
|
||||||
|
|
||||||
**First-time setup:** Put your `ANTHROPIC_API_KEY` in `.env` in the main repo (see `.env.example`). Every Conductor workspace inherits it automatically.
|
**First-time setup:** Put your `ANTHROPIC_API_KEY` in `.env` in the main repo (see `.env.example`). Every Conductor workspace inherits it automatically.
|
||||||
|
|
||||||
**`GSTACK_*` env prefix (Conductor-injected keys).** Conductor explicitly strips `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from every workspace's process env. The `.env` copy path doesn't restore them either — the strip happens after env inheritance. Users who want paid evals, `/sync-gbrain` embeddings, or `claude-agent-sdk` calls to work in a Conductor workspace must set `GSTACK_ANTHROPIC_API_KEY` and `GSTACK_OPENAI_API_KEY` in Conductor's workspace env config; Conductor passes those through untouched. On the gstack side, TS entry points import `lib/conductor-env-shim.ts` as a side effect, which promotes `GSTACK_FOO_API_KEY` to `FOO_API_KEY` when the canonical name is empty. If you add a new TS entry point that hits a paid API, add `import "../lib/conductor-env-shim";` to the top of the file. Today the shim is imported from `bin/gstack-gbrain-sync.ts`, `bin/gstack-model-benchmark`, `scripts/preflight-agent-sdk.ts`, and `test/helpers/e2e-helpers.ts`.
|
**`GSTACK_*` env prefix (Conductor-injected keys).** Conductor explicitly strips `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from every workspace's process env. The `.env` copy path doesn't restore them either — the strip happens after env inheritance. Users who want paid evals, `/sync-gbrain` embeddings, or `claude-agent-sdk` calls to work in a Conductor workspace must set `GSTACK_ANTHROPIC_API_KEY` and `GSTACK_OPENAI_API_KEY` in Conductor's workspace env config; Conductor passes those through untouched. On the gstack side, TS entry points import `lib/conductor-env-shim.ts` as a side effect, which promotes `GSTACK_FOO_API_KEY` to `FOO_API_KEY` when the canonical name is empty. If you add a new TS entry point that hits a paid API, add `import "../lib/conductor-env-shim";` to the top of the file. Today the shim is imported from `bin/gstack-gbrain-sync.ts`, `bin/gstack-model-benchmark`, `scripts/preflight-agent-sdk.ts`, and `test/helpers/e2e-helpers.ts`.
|
||||||
|
|
|
||||||
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)
|
||||||
|
|
|
||||||
200
TODOS.md
200
TODOS.md
|
|
@ -1,5 +1,24 @@
|
||||||
# TODOS
|
# TODOS
|
||||||
|
|
||||||
|
## Test infrastructure
|
||||||
|
|
||||||
|
### ✅ DONE (v1.53.1.0): Rebaseline parity-suite (v1.44.1 → v1.53.0.0)
|
||||||
|
|
||||||
|
**What:** `test/parity-suite.test.ts` checked every skill's SKILL.md size against
|
||||||
|
the frozen `test/fixtures/parity-baseline-v1.44.1.json`. Five planning skills had
|
||||||
|
crept past the 1.05x ceiling: `plan-ceo-review` (1.052), `plan-eng-review` (1.062),
|
||||||
|
`plan-design-review` (1.068), `investigate` (1.053), `office-hours` (1.065) — growth
|
||||||
|
from the brain-aware-planning releases (v1.49–v1.52) plus the v1.53 redaction guard.
|
||||||
|
|
||||||
|
**Resolved:** Captured a fresh baseline at HEAD via
|
||||||
|
`bun run scripts/capture-baseline.ts --tag v1.53.0.0` and re-pointed the test at
|
||||||
|
`test/fixtures/parity-baseline-v1.53.0.0.json`. The per-skill 1.05 ratio is kept, so
|
||||||
|
future bloat is still caught — only the stale anchor moved. Mirrors the earlier
|
||||||
|
`skill-size-budget` rebase (v1.44.1 → v1.47.0.0). Historical v1.44.1 / v1.46.0.0 /
|
||||||
|
v1.47.0.0 baselines retained in `test/fixtures/` for the v1→v2 audit trail. The
|
||||||
|
captured skill bytes match `origin/main` exactly (the rebasing branch left every
|
||||||
|
SKILL.md untouched). `bun test` is green again.
|
||||||
|
|
||||||
## gbrowser memory follow-ups (filed via /plan-eng-review + /codex on the v1.49 leak-fix PR)
|
## gbrowser memory follow-ups (filed via /plan-eng-review + /codex on the v1.49 leak-fix PR)
|
||||||
|
|
||||||
These four items came out of the memory-leak investigation that shipped
|
These four items came out of the memory-leak investigation that shipped
|
||||||
|
|
@ -717,7 +736,24 @@ reads it yet.
|
||||||
|
|
||||||
**Effort:** L (human: ~1 week / CC: ~4h)
|
**Effort:** L (human: ~1 week / CC: ~4h)
|
||||||
**Priority:** P0
|
**Priority:** P0
|
||||||
**Depends on:** 2+ weeks of v1 dogfood, profile diversity check passing.
|
**Depends on:** **90+ days of v1 dogfood stable across 3+ skills** (per
|
||||||
|
`docs/designs/PLAN_TUNING_V0.md` §"Deferred to v2" E1 acceptance criteria).
|
||||||
|
Distinct from the lighter-weight diversity-display gate
|
||||||
|
(`sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8
|
||||||
|
AND days_span >= 7`) used in /plan-tune to render the inferred column —
|
||||||
|
display is a UI affordance, promotion to E1 needs a much higher bar
|
||||||
|
because behavioral adaptation is consequential and hard to revert. Prior
|
||||||
|
versions of this card cited "2+ weeks" which conflicted with V0 — V0 wins.
|
||||||
|
|
||||||
|
**Substrate risk (Codex outside-voice, Phase A review 2026-05-26):** Generated
|
||||||
|
skill prose is agent-compliance-based. Tests can verify templates contain the
|
||||||
|
right reads of `~/.gstack/developer-profile.json` and the right decision
|
||||||
|
points, but tests cannot prove agents obey them at runtime. E1 ships
|
||||||
|
adaptations as **advisory annotations on AskUserQuestion recommendations**
|
||||||
|
("Recommended via your profile: <choice>") until there's a hard runtime
|
||||||
|
execution path. Do NOT gate any AUTO_DECIDE on inferred profile alone in v1
|
||||||
|
of E1; explicit per-question preferences remain the only AUTO_DECIDE
|
||||||
|
source.
|
||||||
|
|
||||||
### E3 — `/plan-tune narrative` + `/plan-tune vibe`
|
### E3 — `/plan-tune narrative` + `/plan-tune vibe`
|
||||||
|
|
||||||
|
|
@ -2053,3 +2089,165 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr
|
||||||
### Auto-upgrade mode + smart update check
|
### Auto-upgrade mode + smart update check
|
||||||
- Config CLI (`bin/gstack-config`), auto-upgrade via `~/.gstack/config.yaml`, 12h cache TTL, exponential snooze backoff (24h→48h→1wk), "never ask again" option, vendored copy sync on upgrade
|
- Config CLI (`bin/gstack-config`), auto-upgrade via `~/.gstack/config.yaml`, 12h cache TTL, exponential snooze backoff (24h→48h→1wk), "never ask again" option, vendored copy sync on upgrade
|
||||||
**Completed:** v0.3.8
|
**Completed:** v0.3.8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brain-aware planning follow-ups (filed v1.48.0.0 via /plan-ceo-review + /plan-eng-review)
|
||||||
|
|
||||||
|
These are the deferred cherry-picks (E2/E3/E4) from the v1.48 brain-aware
|
||||||
|
planning plan at `~/.claude/plans/hm-interesting-well-why-dapper-eagle.md`.
|
||||||
|
The foundation (Phase 0 entity model + Phase 0.5 cache + Phase 1 preflight
|
||||||
|
+ Phase 1.5 trust policy + Phase 2 write-back scaffolding) ships in
|
||||||
|
v1.48.0.0. These follow-ups extend it.
|
||||||
|
|
||||||
|
### P2: /gstack-reflect nightly synthesis skill (E2)
|
||||||
|
|
||||||
|
**What:** Scheduled skill that reads weekly `gstack/skill-run` + takes +
|
||||||
|
`get_recent_salience` and synthesizes a `gstack/insight` page surfaced at
|
||||||
|
next skill preflight.
|
||||||
|
|
||||||
|
**Why:** Cross-time pattern detection is the compounding move. "You ran 4
|
||||||
|
plan-ceo on infra this week, 0 on product — is product work getting
|
||||||
|
starved?" surfaces patterns the user wouldn't notice.
|
||||||
|
|
||||||
|
**Pros:** Brain compounds across TIME, not just across skills. Patterns
|
||||||
|
become actionable.
|
||||||
|
|
||||||
|
**Cons:** "You're starving product work" is high-judgment territory; needs
|
||||||
|
opt-out per project, careful insight templates.
|
||||||
|
|
||||||
|
**Context:** Deferred from v1.48.0.0 cherry-pick (D4) — wait 4-6 weeks for
|
||||||
|
real `gstack/skill-run` data to accumulate before designing the reflection
|
||||||
|
layer against real patterns instead of imagined ones.
|
||||||
|
|
||||||
|
**Effort:** L (human ~1-2 days, CC ~4-6h)
|
||||||
|
|
||||||
|
**Depends on:** Phase 0 (gstack/skill-run page type from v1.48.0.0) +
|
||||||
|
~6 weeks of accumulated data
|
||||||
|
|
||||||
|
### P3: Cross-machine brain-cache sync (E3)
|
||||||
|
|
||||||
|
**What:** Push compressed digests through the gstack-brain-sync git pipeline
|
||||||
|
so the brain-cache survives moving between Macs / Conductor workspaces.
|
||||||
|
|
||||||
|
**Why:** Eliminates the cold-miss tax on every new machine (~1-2s once per
|
||||||
|
machine per day).
|
||||||
|
|
||||||
|
**Pros:** Instant warm cache on new machines.
|
||||||
|
|
||||||
|
**Cons:** Cache poisoning risk if not designed carefully (hash invariants,
|
||||||
|
endpoint-binding, conflict resolution).
|
||||||
|
|
||||||
|
**Context:** Deferred from v1.48.0.0 cherry-pick (D5) — single-machine
|
||||||
|
cache is fine for V1; correctness risk needs its own design pass.
|
||||||
|
|
||||||
|
**Effort:** M (human ~4h, CC ~30min)
|
||||||
|
|
||||||
|
**Depends on:** Brain-cache layer from v1.48.0.0
|
||||||
|
|
||||||
|
### P3: /gstack-onboarding dedicated skill (E4)
|
||||||
|
|
||||||
|
**What:** Guided 5-minute setup skill for new gstack installs: walks user
|
||||||
|
through reading CLAUDE.md + README + recent commits to build `gstack/product`
|
||||||
|
and active goals with explicit AUQs.
|
||||||
|
|
||||||
|
**Why:** Better UX than the inline bootstrap (which only fires when a
|
||||||
|
planning skill is invoked).
|
||||||
|
|
||||||
|
**Pros:** Cleaner cold-start, explicit ceremony.
|
||||||
|
|
||||||
|
**Cons:** Inline bootstrap (in scope for v1.48) already covers the
|
||||||
|
cold-start path adequately.
|
||||||
|
|
||||||
|
**Context:** Deferred from v1.48.0.0 cherry-pick (D6) — observe inline
|
||||||
|
bootstrap performance first; add dedicated skill if friction is real.
|
||||||
|
|
||||||
|
**Effort:** S (human ~2h, CC ~15min)
|
||||||
|
|
||||||
|
**Depends on:** Inline bootstrap subcommand from v1.48.0.0
|
||||||
|
|
||||||
|
### P2: Upstream gbrain takes_add + takes_resolve MCP ops
|
||||||
|
|
||||||
|
**What:** Add `mcp__gbrain__takes_add` and `mcp__gbrain__takes_resolve`
|
||||||
|
ops in `~/git/gbrain/src/core/operations.ts`. Extract the markdown-fence
|
||||||
|
mirror logic from `commands/takes.ts:570` into a reusable
|
||||||
|
`engine.resolveTake()` helper.
|
||||||
|
|
||||||
|
**Why:** Unlocks Phase 2 calibration write-back without the fence-block
|
||||||
|
fallback. ~150 LOC. Already on gbrain's v0.31.x roadmap.
|
||||||
|
|
||||||
|
**Pros:** Clean Phase 2 path, removes the "fall back to put_page" smell.
|
||||||
|
|
||||||
|
**Cons:** Lives in upstream gbrain repo, not helsinki — separate PR.
|
||||||
|
|
||||||
|
**Context:** Phase 2 write-back is already wired in v1.48.0.0 behind the
|
||||||
|
BRAIN_CALIBRATION_WRITEBACK feature flag (default off). Flag flips to
|
||||||
|
true once upstream gbrain ships these ops. ~50 LOC follow-up in
|
||||||
|
helsinki to swap the fallback for the preferred op.
|
||||||
|
|
||||||
|
**Effort:** S (human ~1d, CC ~1h) in gbrain repo; trivial wire-up in
|
||||||
|
helsinki.
|
||||||
|
|
||||||
|
**Depends on:** None (parallel-track from v1.48.0.0)
|
||||||
|
|
||||||
|
### P3: Background-refresh hook supervision
|
||||||
|
|
||||||
|
**What:** Codex outside-voice raised that "background refresh at skill END"
|
||||||
|
is hand-wavy. Add proper process supervision: PID file, timeout, failure
|
||||||
|
log, cross-platform spawn.
|
||||||
|
|
||||||
|
**Why:** Current implementation backgrounds with `&` which works but
|
||||||
|
leaves no observability when a refresh fails.
|
||||||
|
|
||||||
|
**Context:** Deferred from v1.48.0.0 codex tension T3. Stays low priority
|
||||||
|
until users report stale digests where a background refresh silently
|
||||||
|
failed.
|
||||||
|
|
||||||
|
**Effort:** S (human ~2h, CC ~20min)
|
||||||
|
|
||||||
|
### P2: Re-verify calibration takes when gbrain v0.42+ lands
|
||||||
|
|
||||||
|
**What:** When upstream gbrain ships `takes_add` MCP op and we flip
|
||||||
|
`BRAIN_CALIBRATION_WRITEBACK` from FALSE to TRUE, re-run the manual
|
||||||
|
probe in `docs/gbrain-write-surfaces.md` against `/office-hours` and
|
||||||
|
confirm `gbrain takes_list` surfaces a `kind=bet` entry with the
|
||||||
|
expected weight (0.9 for office-hours, per
|
||||||
|
`scripts/brain-cache-spec.ts:151-157`).
|
||||||
|
|
||||||
|
**Why:** Today the calibration take path falls back to writing inside a
|
||||||
|
`gbrain put` fence block because `takes_add` isn't available yet. Once
|
||||||
|
v0.42+ ships, the agent will call `takes_add` directly — we should
|
||||||
|
confirm the new path actually persists a queryable take.
|
||||||
|
|
||||||
|
**Context:** v1.50.0.0 plan §"NOT in scope". The fence-block fallback
|
||||||
|
test (`test/takes-fence-fallback.test.ts`) covers wiring for both paths;
|
||||||
|
this TODO is about live verification of the preferred path when it
|
||||||
|
becomes available.
|
||||||
|
|
||||||
|
**Effort:** XS (human ~15min, CC ~5min)
|
||||||
|
|
||||||
|
**Depends on:** Upstream gbrain v0.42+ release shipping `takes_add` MCP
|
||||||
|
op (separate TODO above).
|
||||||
|
|
||||||
|
### P2: Extend brain-writeback E2E to the other 4 planning skills
|
||||||
|
|
||||||
|
**What:** `test/skill-e2e-office-hours-brain-writeback.test.ts` covers
|
||||||
|
the brain-writeback path for `/office-hours` only. Adding parallel
|
||||||
|
tests for `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`,
|
||||||
|
and `/plan-devex-review` would bring per-skill agent-obedience coverage
|
||||||
|
to parity with the resolver unit test
|
||||||
|
(`test/resolvers-gbrain-save-results.test.ts`, which covers wiring for
|
||||||
|
all 5).
|
||||||
|
|
||||||
|
**Why:** The resolver test proves the right instructions get emitted;
|
||||||
|
the E2E proves the agent actually obeys. Today we only have that
|
||||||
|
end-to-end signal for one of five planning skills.
|
||||||
|
|
||||||
|
**Context:** v1.50.0.0 plan §"NOT in scope". Extract `makeFakeGbrain`
|
||||||
|
into `test/helpers/fake-gbrain.ts` when the second consumer arrives
|
||||||
|
(YAGNI for one consumer today).
|
||||||
|
|
||||||
|
**Effort:** S (human ~1d, CC ~1h). Periodic-tier (~$2-4 total for 4
|
||||||
|
runs).
|
||||||
|
|
||||||
|
**Depends on:** None.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -654,7 +654,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"autoplan","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"autoplan","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,23 @@ if [ ! -e "$AGENTS_LINK" ]; then
|
||||||
ln -s "$REPO_ROOT" "$AGENTS_LINK"
|
ln -s "$REPO_ROOT" "$AGENTS_LINK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 6. Run setup via the symlink so it detects .claude/skills/ as its parent
|
# 6. Run setup via the symlink so it detects .claude/skills/ as its parent.
|
||||||
"$GSTACK_LINK/setup"
|
#
|
||||||
|
# Workspace/dev setup MUST be non-interactive: Conductor runs this under a
|
||||||
|
# forwarded pty, so any `read` in setup (skill-prefix prompt, plan-tune hook
|
||||||
|
# consent) would hang the workspace forever. Detaching stdin makes every setup
|
||||||
|
# prompt take its smart non-interactive default (flat skill names, etc.).
|
||||||
|
#
|
||||||
|
# `--plan-tune-hooks=prompt` is load-bearing, not redundant: stdin alone only
|
||||||
|
# suppresses the *prompt* branch. A saved `plan_tune_hooks: yes` or an exported
|
||||||
|
# GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the
|
||||||
|
# user's global ~/.claude/settings.json to point at THIS ephemeral worktree —
|
||||||
|
# which breaks once the workspace is deleted. The flag has highest precedence,
|
||||||
|
# so it pins resolution to "prompt", and closed stdin then makes prompt-mode a
|
||||||
|
# no-op skip (no install, no decline marker). A dev workspace must never mutate
|
||||||
|
# global settings.json. To install the hooks, run `./setup --plan-tune-hooks`
|
||||||
|
# directly (outside dev-setup). Saved prefix/other config preferences still apply.
|
||||||
|
"$GSTACK_LINK/setup" --plan-tune-hooks=prompt </dev/null
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Dev mode active. Skills resolve from this working tree."
|
echo "Dev mode active. Skills resolve from this working tree."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,949 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* gstack-brain-cache — three-tier cache for brain-aware planning skills.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* get <entity-name> [--project <slug>] — return digest content; refresh if stale
|
||||||
|
* refresh [--full] [--entity X] [--project <slug>] — force refresh one or all
|
||||||
|
* invalidate <entity-name> [--project <slug>] — mark stale; next get triggers cold
|
||||||
|
* digest <entity-slug> — compress a brain page slug to digest
|
||||||
|
* meta [--project <slug>] — print _meta.json
|
||||||
|
*
|
||||||
|
* (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].)
|
||||||
|
*
|
||||||
|
* Cache layout:
|
||||||
|
* ~/.gstack/brain-cache/ ← cross-project (user-profile only)
|
||||||
|
* ~/.gstack/projects/<slug>/brain-cache/ ← per-project (everything else)
|
||||||
|
*
|
||||||
|
* Atomic writes via .tmp + rename. Stale-but-usable fallback when brain
|
||||||
|
* unreachable. Concurrent-refresh dedup is a follow-up commit (T15).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync, openSync, closeSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { homedir, hostname } from 'os';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec';
|
||||||
|
import {
|
||||||
|
BRAIN_CACHE_ENTITIES,
|
||||||
|
CACHE_REFRESH_LOCK_TIMEOUT_MS,
|
||||||
|
GSTACK_SCHEMA_PACK_NAME,
|
||||||
|
GSTACK_SCHEMA_PACK_VERSION,
|
||||||
|
SALIENCE_DEFAULT_ALLOWLIST,
|
||||||
|
type BrainCacheEntity,
|
||||||
|
} from '../scripts/brain-cache-spec';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Paths + meta
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack');
|
||||||
|
|
||||||
|
interface CacheMeta {
|
||||||
|
/** Version of the schema pack the cache was built against. Mismatch → full rebuild. */
|
||||||
|
schema_version: string;
|
||||||
|
/** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */
|
||||||
|
endpoint_hash: string;
|
||||||
|
/** Per-entity last-refresh epoch ms. Absent → never refreshed. */
|
||||||
|
last_refresh: Record<string, number>;
|
||||||
|
/** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */
|
||||||
|
last_attempt?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the directory holding a given entity's cache file. */
|
||||||
|
export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string {
|
||||||
|
if (entity.scope === 'cross-project') {
|
||||||
|
return join(GSTACK_HOME, 'brain-cache');
|
||||||
|
}
|
||||||
|
if (!projectSlug) {
|
||||||
|
throw new Error(`Per-project entity needs a project slug: ${entity.file}`);
|
||||||
|
}
|
||||||
|
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the path to the cache file for a given entity. */
|
||||||
|
export function entityPath(entityName: string, projectSlug: string | null): string {
|
||||||
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
||||||
|
if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
|
||||||
|
return join(entityDir(entity, projectSlug), entity.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the path to the _meta.json for a given scope. */
|
||||||
|
export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string {
|
||||||
|
if (scope === 'cross-project') {
|
||||||
|
return join(GSTACK_HOME, 'brain-cache', '_meta.json');
|
||||||
|
}
|
||||||
|
if (!projectSlug) throw new Error('Per-project meta needs a project slug');
|
||||||
|
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta {
|
||||||
|
const path = metaPath(scope, projectSlug);
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
|
||||||
|
} catch {
|
||||||
|
// Corrupt _meta — start fresh (entries will refresh on next access).
|
||||||
|
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void {
|
||||||
|
const path = metaPath(scope, projectSlug);
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
atomicWrite(path, JSON.stringify(meta, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Endpoint hash detection
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
function sha8(input: string): string {
|
||||||
|
return createHash('sha256').update(input).digest('hex').slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the active brain endpoint (MCP URL or 'local') and returns its
|
||||||
|
* stable identity hash. Used to detect when the user switches brains
|
||||||
|
* (different endpoint → different cache).
|
||||||
|
*/
|
||||||
|
export function detectEndpointHash(): string {
|
||||||
|
const claudeJsonPath = join(homedir(), '.claude.json');
|
||||||
|
if (existsSync(claudeJsonPath)) {
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
|
||||||
|
const gbrainServer = cfg?.mcpServers?.gbrain;
|
||||||
|
const url = gbrainServer?.url || gbrainServer?.transport?.url;
|
||||||
|
if (typeof url === 'string' && url.length > 0) {
|
||||||
|
return sha8(url);
|
||||||
|
}
|
||||||
|
} catch { /* fall through to local */ }
|
||||||
|
}
|
||||||
|
// Local engine — no endpoint URL; use a stable literal hash.
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Atomic write (tmp + rename)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function atomicWrite(path: string, content: string): void {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
||||||
|
writeFileSync(tmp, content, 'utf-8');
|
||||||
|
renameSync(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Staleness + refresh logic
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns true if the cached digest is past its TTL. */
|
||||||
|
function isStale(entityName: string, meta: CacheMeta): boolean {
|
||||||
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
||||||
|
if (!entity) return true;
|
||||||
|
const last = meta.last_refresh[entityName];
|
||||||
|
if (!last) return true;
|
||||||
|
return Date.now() - last > entity.ttl_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the cache file exists on disk. */
|
||||||
|
function hasFile(entityName: string, projectSlug: string | null): boolean {
|
||||||
|
return existsSync(entityPath(entityName, projectSlug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if schema version recorded in meta differs from current pack version. */
|
||||||
|
function schemaVersionMismatch(meta: CacheMeta): boolean {
|
||||||
|
return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */
|
||||||
|
function endpointSwitched(meta: CacheMeta): boolean {
|
||||||
|
return meta.endpoint_hash !== detectEndpointHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: get
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GetResult {
|
||||||
|
/** Path to the digest file. */
|
||||||
|
path: string;
|
||||||
|
/** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */
|
||||||
|
state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing';
|
||||||
|
/** Optional message for diagnostics. */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cmdGet(entityName: string, projectSlug: string | null): GetResult {
|
||||||
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
||||||
|
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
|
||||||
|
const scope = entity.scope;
|
||||||
|
const meta = loadMeta(scope, projectSlug);
|
||||||
|
|
||||||
|
// Schema-version mismatch → full rebuild (D4 A4).
|
||||||
|
if (schemaVersionMismatch(meta) || endpointSwitched(meta)) {
|
||||||
|
rebuildAllForScope(scope, projectSlug);
|
||||||
|
// After rebuild, meta is fresh; fall through to warm path.
|
||||||
|
const newMeta = loadMeta(scope, projectSlug);
|
||||||
|
if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) {
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'warm' };
|
||||||
|
}
|
||||||
|
// Rebuild may have failed for this entity specifically.
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) {
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'warm' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale or missing — try cold refresh.
|
||||||
|
const refreshed = refreshEntity(entityName, projectSlug);
|
||||||
|
if (refreshed) {
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' };
|
||||||
|
}
|
||||||
|
// Refresh failed. Use stale-but-usable if file exists.
|
||||||
|
if (hasFile(entityName, projectSlug)) {
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' };
|
||||||
|
}
|
||||||
|
// No cache and no refresh = missing.
|
||||||
|
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: refresh
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Lockfile dedup (T15 / D3)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the lock file path for a project scope. Cross-project entities
|
||||||
|
* still lock per-project (the project triggering the refresh holds the lock);
|
||||||
|
* concurrent attempts from different projects on cross-project entities
|
||||||
|
* serialize naturally because they're rare and the lock window is short.
|
||||||
|
*/
|
||||||
|
function lockPath(projectSlug: string | null): string {
|
||||||
|
const dir = projectSlug
|
||||||
|
? join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache')
|
||||||
|
: join(GSTACK_HOME, 'brain-cache');
|
||||||
|
return join(dir, '.refresh.lock');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LockHandle {
|
||||||
|
fd: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to acquire the refresh lock. Returns null when another process holds it
|
||||||
|
* (and the lock is fresh). Stale locks (process dead OR older than the
|
||||||
|
* timeout) are taken over.
|
||||||
|
*/
|
||||||
|
function tryAcquireLock(projectSlug: string | null): LockHandle | null {
|
||||||
|
const path = lockPath(projectSlug);
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
|
||||||
|
// If a lock exists, see if it's stale
|
||||||
|
if (existsSync(path)) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
const lock = JSON.parse(raw) as { pid: number; host: string; ts: number };
|
||||||
|
const age = Date.now() - lock.ts;
|
||||||
|
const sameHost = lock.host === hostname();
|
||||||
|
const processGone = sameHost && lock.pid > 0 && !isPidAlive(lock.pid);
|
||||||
|
if (age <= CACHE_REFRESH_LOCK_TIMEOUT_MS && !processGone) {
|
||||||
|
return null; // someone else holds a fresh lock
|
||||||
|
}
|
||||||
|
// Stale: take over
|
||||||
|
} catch {
|
||||||
|
// Corrupt lock file → take over
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write our lock (best-effort O_EXCL via tmp+rename for atomic creation)
|
||||||
|
const payload = JSON.stringify({ pid: process.pid, host: hostname(), ts: Date.now() });
|
||||||
|
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
||||||
|
try {
|
||||||
|
writeFileSync(tmp, payload);
|
||||||
|
renameSync(tmp, path);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race: another process may have raced us. Re-read and verify ownership.
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
const lock = JSON.parse(raw) as { pid: number; host: string };
|
||||||
|
if (lock.pid !== process.pid || lock.host !== hostname()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { fd: -1, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseLock(handle: LockHandle): void {
|
||||||
|
try { unlinkSync(handle.path); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPidAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'EPERM') return true; // exists but we don't own it
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a refresh callback under the project-scoped lock. If another refresh is
|
||||||
|
* already in flight, returns 'dedup' and the caller can either wait + retry
|
||||||
|
* (the resolver does this) or fall through to stale-but-usable. Stale locks
|
||||||
|
* (process dead, or older than CACHE_REFRESH_LOCK_TIMEOUT_MS) are taken over.
|
||||||
|
*/
|
||||||
|
export function withRefreshLock<T>(projectSlug: string | null, fn: () => T): T | 'dedup' {
|
||||||
|
const handle = tryAcquireLock(projectSlug);
|
||||||
|
if (!handle) return 'dedup';
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
releaseLock(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes one entity from the brain. Returns true on success. */
|
||||||
|
export function refreshEntity(entityName: string, projectSlug: string | null): boolean {
|
||||||
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
||||||
|
if (!entity) return false;
|
||||||
|
|
||||||
|
// Mark attempt
|
||||||
|
const meta = loadMeta(entity.scope, projectSlug);
|
||||||
|
meta.last_attempt = meta.last_attempt || {};
|
||||||
|
meta.last_attempt[entityName] = Date.now();
|
||||||
|
|
||||||
|
// Fetch from brain. The actual fetch logic varies per entity — derived digests
|
||||||
|
// (recent-decisions, salience) need different queries from direct page reads.
|
||||||
|
// For T2a we implement the direct-page path; derived digests get filled in by
|
||||||
|
// the resolver / write-back paths in later commits.
|
||||||
|
const digestContent = fetchAndCompressEntity(entityName, projectSlug);
|
||||||
|
if (digestContent === null) {
|
||||||
|
saveMeta(entity.scope, projectSlug, meta);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-entity budget by truncating from end (oldest items live there
|
||||||
|
// by convention in our compressor). The per-skill budget is separately
|
||||||
|
// enforced at preflight injection time.
|
||||||
|
let final = digestContent;
|
||||||
|
if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) {
|
||||||
|
final = truncateToBudget(final, entity.budget_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicWrite(entityPath(entityName, projectSlug), final);
|
||||||
|
meta.last_refresh[entityName] = Date.now();
|
||||||
|
// Keep schema/endpoint identity fresh.
|
||||||
|
meta.schema_version = GSTACK_SCHEMA_PACK_VERSION;
|
||||||
|
meta.endpoint_hash = detectEndpointHash();
|
||||||
|
saveMeta(entity.scope, projectSlug, meta);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh all entities for a scope (per-project or cross-project).
|
||||||
|
* Used by --full and by schema/endpoint-change rebuilds.
|
||||||
|
*/
|
||||||
|
export function refreshAll(projectSlug: string | null): { success: number; failed: number } {
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
||||||
|
// Cross-project entities only refresh when explicitly targeted via no-slug calls
|
||||||
|
if (entity.scope === 'cross-project' && projectSlug) continue;
|
||||||
|
if (entity.scope === 'per-project' && !projectSlug) continue;
|
||||||
|
if (refreshEntity(name, projectSlug)) success++; else failed++;
|
||||||
|
}
|
||||||
|
return { success, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */
|
||||||
|
function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void {
|
||||||
|
// Wipe files but preserve dir; meta gets fully rewritten by refreshes below.
|
||||||
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
||||||
|
if (entity.scope !== scope) continue;
|
||||||
|
const p = entityPath(name, projectSlug);
|
||||||
|
if (existsSync(p)) {
|
||||||
|
try { unlinkSync(p); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fresh meta starts here
|
||||||
|
const fresh: CacheMeta = {
|
||||||
|
schema_version: GSTACK_SCHEMA_PACK_VERSION,
|
||||||
|
endpoint_hash: detectEndpointHash(),
|
||||||
|
last_refresh: {},
|
||||||
|
last_attempt: {},
|
||||||
|
};
|
||||||
|
saveMeta(scope, projectSlug, fresh);
|
||||||
|
// Refresh all entities in this scope
|
||||||
|
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
|
||||||
|
if (entity.scope !== scope) continue;
|
||||||
|
refreshEntity(name, projectSlug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: invalidate
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cmdInvalidate(entityName: string, projectSlug: string | null): void {
|
||||||
|
const entity = BRAIN_CACHE_ENTITIES[entityName];
|
||||||
|
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
|
||||||
|
const meta = loadMeta(entity.scope, projectSlug);
|
||||||
|
delete meta.last_refresh[entityName];
|
||||||
|
saveMeta(entity.scope, projectSlug, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Fetch + compress per-entity
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the digest markdown content for an entity, or null if the brain is
|
||||||
|
* unreachable / the source page doesn't exist.
|
||||||
|
*
|
||||||
|
* For T2a we implement the entity → page-slug mapping for the simple cases.
|
||||||
|
* Derived digests (recent-decisions, salience) get specialized paths.
|
||||||
|
*/
|
||||||
|
function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null {
|
||||||
|
switch (entityName) {
|
||||||
|
case 'user-profile':
|
||||||
|
return fetchUserProfile();
|
||||||
|
case 'product':
|
||||||
|
return fetchProduct(projectSlug);
|
||||||
|
case 'goals':
|
||||||
|
return fetchGoals(projectSlug);
|
||||||
|
case 'developer-persona':
|
||||||
|
return fetchSimplePage(`gstack/developer-persona/${projectSlug}`);
|
||||||
|
case 'brand':
|
||||||
|
return fetchSimplePage(`gstack/brand/${projectSlug}`);
|
||||||
|
case 'competitive-intel':
|
||||||
|
return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`);
|
||||||
|
case 'recent-decisions':
|
||||||
|
return fetchRecentDecisions(projectSlug);
|
||||||
|
case 'salience':
|
||||||
|
// D9 salience allowlist applied in T17 commit; T2a returns raw output for now.
|
||||||
|
return fetchSalience(projectSlug);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */
|
||||||
|
function fetchSimplePage(slug: string): string | null {
|
||||||
|
const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 });
|
||||||
|
if (result.status !== 0) return null;
|
||||||
|
try {
|
||||||
|
const page = JSON.parse(result.stdout) as { body?: string; title?: string };
|
||||||
|
if (!page?.body) return null;
|
||||||
|
return compressPage(slug, page.title || slug, page.body);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchUserProfile(): string | null {
|
||||||
|
// The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept
|
||||||
|
// env GSTACK_USER_SLUG as override, fallback to $USER for direct calls.
|
||||||
|
const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown';
|
||||||
|
return fetchSimplePage(`gstack/user-profile/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchProduct(projectSlug: string | null): string | null {
|
||||||
|
if (!projectSlug) return null;
|
||||||
|
return fetchSimplePage(`gstack/product/${projectSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goals are LIST queries: all gstack/goal/<project>/* pages.
|
||||||
|
* Compress the top N by recency.
|
||||||
|
*/
|
||||||
|
function fetchGoals(projectSlug: string | null): string | null {
|
||||||
|
if (!projectSlug) return null;
|
||||||
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([
|
||||||
|
'list-pages',
|
||||||
|
'--type', 'gstack/goal',
|
||||||
|
'--limit', '10',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
if (!result?.pages) return null;
|
||||||
|
const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`));
|
||||||
|
if (goals.length === 0) {
|
||||||
|
// Empty digest is valid (just header + 'no active goals' line)
|
||||||
|
return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`;
|
||||||
|
}
|
||||||
|
const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`);
|
||||||
|
return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recent-decisions: last 5 gstack/skill-run pages for this project, compressed
|
||||||
|
* to one-line summaries.
|
||||||
|
*/
|
||||||
|
function fetchRecentDecisions(projectSlug: string | null): string | null {
|
||||||
|
if (!projectSlug) return null;
|
||||||
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
|
||||||
|
'list-pages',
|
||||||
|
'--type', 'gstack/skill-run',
|
||||||
|
'--limit', '5',
|
||||||
|
'--sort', 'updated_desc',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
if (!result?.pages) {
|
||||||
|
return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`;
|
||||||
|
}
|
||||||
|
const lines = result.pages.map((p) => `- ${p.title || p.slug}`);
|
||||||
|
return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the user's salience allowlist override from gstack-config. If unset,
|
||||||
|
* returns SALIENCE_DEFAULT_ALLOWLIST. The override is comma-separated; we
|
||||||
|
* trim and drop empty entries.
|
||||||
|
*/
|
||||||
|
export function getSalienceAllowlist(): ReadonlyArray<string> {
|
||||||
|
// Short-circuit via env var for tests + headless callers.
|
||||||
|
const env = process.env.GSTACK_SALIENCE_ALLOWLIST;
|
||||||
|
if (typeof env === 'string' && env.length > 0) {
|
||||||
|
return env.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
// Shell out to gstack-config with a tight timeout. Falls back to defaults
|
||||||
|
// on any failure (config script missing, command non-zero, parse error).
|
||||||
|
try {
|
||||||
|
const skillRoot = join(homedir(), '.claude', 'skills', 'gstack');
|
||||||
|
const bin = join(skillRoot, 'bin', 'gstack-config');
|
||||||
|
if (!existsSync(bin)) return SALIENCE_DEFAULT_ALLOWLIST;
|
||||||
|
const result = spawnSync(bin, ['get', 'salience_allowlist'], { timeout: 2000, encoding: 'utf-8' });
|
||||||
|
if (result.status !== 0 || !result.stdout) return SALIENCE_DEFAULT_ALLOWLIST;
|
||||||
|
const trimmed = result.stdout.trim();
|
||||||
|
if (!trimmed) return SALIENCE_DEFAULT_ALLOWLIST;
|
||||||
|
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts : SALIENCE_DEFAULT_ALLOWLIST;
|
||||||
|
} catch {
|
||||||
|
return SALIENCE_DEFAULT_ALLOWLIST;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D9 salience privacy gate: returns true if the slug starts with any allowlisted
|
||||||
|
* prefix. Anything NOT matching is stripped at digest write time so that family,
|
||||||
|
* therapy, reflection, and other sensitive content never leaks into work-flow
|
||||||
|
* planning prompts by default.
|
||||||
|
*/
|
||||||
|
export function isSalienceSlugAllowed(slug: string, allowlist: ReadonlyArray<string>): boolean {
|
||||||
|
for (const prefix of allowlist) {
|
||||||
|
if (slug.startsWith(prefix)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSalience(projectSlug: string | null): string | null {
|
||||||
|
// get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON
|
||||||
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([
|
||||||
|
'get-recent-salience',
|
||||||
|
'--days', '14',
|
||||||
|
'--limit', '10',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`;
|
||||||
|
|
||||||
|
// D9 privacy gate: strip entries outside the allowlist BEFORE rendering.
|
||||||
|
// Sensitive personal content (family, therapy, reflection) is never written
|
||||||
|
// into the digest cache file, even when the brain itself ranks it salient.
|
||||||
|
const allowlist = getSalienceAllowlist();
|
||||||
|
const filtered = result.pages.filter((p) => p.slug && isSalienceSlugAllowed(p.slug, allowlist));
|
||||||
|
const stripped = result.pages.length - filtered.length;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
const header = `# Recent salience (last 14d)`;
|
||||||
|
const note = stripped > 0
|
||||||
|
? `\n_All ${stripped} salient entries stripped by allowlist gate (no work-flow content in window)._\n`
|
||||||
|
: `\n_No salient pages in last 14d._\n`;
|
||||||
|
return `${header}\n${note}`;
|
||||||
|
}
|
||||||
|
const lines = filtered.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`);
|
||||||
|
const footer = stripped > 0
|
||||||
|
? `\n\n_${stripped} private entries stripped by allowlist gate._`
|
||||||
|
: '';
|
||||||
|
return `# Recent salience (last 14d)\n\n${lines.join('\n')}${footer}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress a brain page body into a digest. The compressor keeps frontmatter
|
||||||
|
* out, trims body to the first H2/H3 sections, and prepends a slug header.
|
||||||
|
* Per-entity budget enforcement happens at the caller (refreshEntity).
|
||||||
|
*/
|
||||||
|
function compressPage(slug: string, title: string, body: string): string {
|
||||||
|
const trimmed = body
|
||||||
|
.replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter
|
||||||
|
.trim();
|
||||||
|
return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a digest to a byte budget. Tries to cut at the last newline before
|
||||||
|
* the budget so the digest stays readable.
|
||||||
|
*/
|
||||||
|
function truncateToBudget(content: string, budgetBytes: number): string {
|
||||||
|
const buf = Buffer.from(content, 'utf-8');
|
||||||
|
if (buf.byteLength <= budgetBytes) return content;
|
||||||
|
const truncated = buf.slice(0, budgetBytes).toString('utf-8');
|
||||||
|
const lastNewline = truncated.lastIndexOf('\n');
|
||||||
|
const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated;
|
||||||
|
return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: digest
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public: compress a brain page slug to digest format. Used by callers that
|
||||||
|
* want to know what the digest WOULD look like without writing to cache.
|
||||||
|
*/
|
||||||
|
export function cmdDigest(slug: string): string | null {
|
||||||
|
return fetchSimplePage(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: meta
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cmdMeta(projectSlug: string | null): CacheMeta {
|
||||||
|
if (projectSlug) return loadMeta('per-project', projectSlug);
|
||||||
|
return loadMeta('cross-project', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: bootstrap (T2b)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap synthesizes draft entity content from CLAUDE.md + README +
|
||||||
|
* recent commits + learnings.jsonl for a fresh project. Emits as JSON for
|
||||||
|
* the caller (skill template) to AUQ-confirm before any write to the brain.
|
||||||
|
*
|
||||||
|
* This keeps the CLI pure (no AUQ logic) while preventing silent
|
||||||
|
* auto-extraction garbage (D10 T4 fix). The agent is responsible for the
|
||||||
|
* "Synthesized X — looks right?" prompt per entity.
|
||||||
|
*/
|
||||||
|
export interface BootstrapDraft {
|
||||||
|
product?: { slug: string; title: string; body: string };
|
||||||
|
goals?: Array<{ slug: string; title: string; body: string }>;
|
||||||
|
developer_persona?: { slug: string; title: string; body: string };
|
||||||
|
brand?: { slug: string; title: string; body: string };
|
||||||
|
competitive_intel?: { slug: string; title: string; body: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cmdBootstrap(projectSlug: string): BootstrapDraft {
|
||||||
|
const draft: BootstrapDraft = {};
|
||||||
|
const repoRoot = process.env.GSTACK_REPO_ROOT || process.cwd();
|
||||||
|
|
||||||
|
// Product synthesis: CLAUDE.md headline + README first paragraph
|
||||||
|
let claudeMd = '';
|
||||||
|
try { claudeMd = readFileSync(join(repoRoot, 'CLAUDE.md'), 'utf-8'); } catch { /* missing is fine */ }
|
||||||
|
let readmeMd = '';
|
||||||
|
try { readmeMd = readFileSync(join(repoRoot, 'README.md'), 'utf-8'); } catch { /* missing is fine */ }
|
||||||
|
|
||||||
|
const productLead = synthesizeProductLead(claudeMd, readmeMd, projectSlug);
|
||||||
|
if (productLead) {
|
||||||
|
draft.product = {
|
||||||
|
slug: `gstack/product/${projectSlug}`,
|
||||||
|
title: projectSlug,
|
||||||
|
body: productLead,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goals: try learnings.jsonl + recent commit messages mentioning "goal" or "ship"
|
||||||
|
const learningsPath = join(GSTACK_HOME, 'projects', projectSlug, 'learnings.jsonl');
|
||||||
|
const goalsHints = synthesizeGoalsHints(learningsPath, repoRoot);
|
||||||
|
if (goalsHints.length > 0) {
|
||||||
|
draft.goals = goalsHints.slice(0, 3).map((hint, idx) => ({
|
||||||
|
slug: `gstack/goal/${projectSlug}/bootstrap-${idx + 1}`,
|
||||||
|
title: hint.title,
|
||||||
|
body: hint.body,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function synthesizeProductLead(claudeMd: string, readmeMd: string, slug: string): string | null {
|
||||||
|
// First H1 in CLAUDE.md or README, plus first paragraph after it.
|
||||||
|
const source = claudeMd || readmeMd;
|
||||||
|
if (!source) return null;
|
||||||
|
const h1Match = source.match(/^#\s+(.+)$/m);
|
||||||
|
const heading = h1Match?.[1]?.trim() || slug;
|
||||||
|
// First non-heading paragraph
|
||||||
|
const paraMatch = source.match(/(?:^|\n)([^#\n][^\n]+(?:\n[^#\n][^\n]+)*)/);
|
||||||
|
const lead = paraMatch?.[1]?.trim() || '(no description found in CLAUDE.md or README)';
|
||||||
|
return [
|
||||||
|
`# ${heading}`,
|
||||||
|
'',
|
||||||
|
'## What',
|
||||||
|
lead.slice(0, 500),
|
||||||
|
'',
|
||||||
|
'## Stage',
|
||||||
|
'(fill in current stage, e.g., v1.x shipped, in development, paused)',
|
||||||
|
'',
|
||||||
|
'## Team',
|
||||||
|
'(fill in team composition + size)',
|
||||||
|
'',
|
||||||
|
'## Active goals',
|
||||||
|
'(populated by /office-hours over time)',
|
||||||
|
'',
|
||||||
|
'## Recent decisions',
|
||||||
|
'(populated by /plan-ceo-review over time)',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function synthesizeGoalsHints(learningsPath: string, repoRoot: string): Array<{ title: string; body: string }> {
|
||||||
|
const hints: Array<{ title: string; body: string }> = [];
|
||||||
|
if (existsSync(learningsPath)) {
|
||||||
|
try {
|
||||||
|
const lines = readFileSync(learningsPath, 'utf-8').split('\n').filter(Boolean);
|
||||||
|
for (const line of lines.slice(-10)) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry?.insight && (entry?.type === 'pattern' || entry?.type === 'architecture')) {
|
||||||
|
hints.push({
|
||||||
|
title: entry.insight.slice(0, 80),
|
||||||
|
body: `Source: learnings.jsonl\nType: ${entry.type}\n\n${entry.insight}\n`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* skip malformed line */ }
|
||||||
|
}
|
||||||
|
} catch { /* unreadable file, skip */ }
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: list (T18)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all gstack-owned pages currently in the brain for a project, grouped
|
||||||
|
* by type. Powers the user's ability to audit what gstack has written.
|
||||||
|
*/
|
||||||
|
export function cmdList(projectSlug: string | null): Array<{ type: string; slug: string; title?: string }> {
|
||||||
|
// We probe each gstack/<type>/ namespace via list-pages with a type filter.
|
||||||
|
const types = ['gstack/user-profile', 'gstack/product', 'gstack/goal', 'gstack/developer-persona', 'gstack/brand', 'gstack/competitive-intel', 'gstack/skill-run', 'gstack/take'];
|
||||||
|
const all: Array<{ type: string; slug: string; title?: string }> = [];
|
||||||
|
for (const type of types) {
|
||||||
|
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
|
||||||
|
'list-pages',
|
||||||
|
'--type', type,
|
||||||
|
'--limit', '200',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
if (!result?.pages) continue;
|
||||||
|
for (const page of result.pages) {
|
||||||
|
if (projectSlug && !page.slug?.includes(`/${projectSlug}`) && type !== 'gstack/user-profile') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
all.push({ type, slug: page.slug, title: page.title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommand: purge (T18)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one gstack-owned page from the brain. Caller (skill template) is
|
||||||
|
* responsible for the confirm prompt; this is the raw operation.
|
||||||
|
*/
|
||||||
|
export function cmdPurge(slug: string): { deleted: boolean; error?: string } {
|
||||||
|
if (!slug.startsWith('gstack/')) {
|
||||||
|
return { deleted: false, error: 'refusing to purge non-gstack page' };
|
||||||
|
}
|
||||||
|
const result = spawnGbrain(['delete-page', slug], { timeout: 10_000 });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return { deleted: false, error: result.stderr?.trim() || `exit ${result.status}` };
|
||||||
|
}
|
||||||
|
// Also invalidate any cached digests that referenced this page.
|
||||||
|
// Best-effort — derived digests may need explicit invalidate.
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI dispatch
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record<string, string | boolean> } {
|
||||||
|
const cmd = argv[2] || '';
|
||||||
|
const rest = argv.slice(3);
|
||||||
|
const positional: string[] = [];
|
||||||
|
const flags: Record<string, string | boolean> = {};
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
const arg = rest[i];
|
||||||
|
if (arg.startsWith('--')) {
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const next = rest[i + 1];
|
||||||
|
if (next && !next.startsWith('--')) {
|
||||||
|
flags[key] = next;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
flags[key] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
positional.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cmd, positional, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSlugFromFlag(flags: Record<string, string | boolean>): string | null {
|
||||||
|
const v = flags.project;
|
||||||
|
return typeof v === 'string' ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
process.stderr.write(`Usage: gstack-brain-cache <subcommand>
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
get <entity-name> [--project <slug>]
|
||||||
|
refresh [--full] [--entity X] [--project <slug>]
|
||||||
|
invalidate <entity-name> [--project <slug>]
|
||||||
|
digest <entity-slug>
|
||||||
|
meta [--project <slug>]
|
||||||
|
bootstrap --project <slug> — emit synthesized entity drafts (JSON)
|
||||||
|
list [--project <slug>] — list gstack-owned pages in brain
|
||||||
|
purge <slug> — delete a gstack-owned brain page (refuses non-gstack/ slugs)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const { cmd, positional, flags } = parseArgs(process.argv);
|
||||||
|
const projectSlug = projectSlugFromFlag(flags);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (cmd) {
|
||||||
|
case 'get': {
|
||||||
|
const entityName = positional[0];
|
||||||
|
if (!entityName) { printUsage(); return 1; }
|
||||||
|
const result = cmdGet(entityName, projectSlug);
|
||||||
|
if (result.state === 'missing') {
|
||||||
|
process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (result.state !== 'warm') {
|
||||||
|
process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`);
|
||||||
|
}
|
||||||
|
process.stdout.write(readFileSync(result.path, 'utf-8'));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'refresh': {
|
||||||
|
// D3: dedup concurrent refreshes via lockfile. Skipped (dedup) when
|
||||||
|
// another process is already mid-refresh on the same project.
|
||||||
|
if (flags.entity) {
|
||||||
|
const entityName = String(flags.entity);
|
||||||
|
const result = withRefreshLock(projectSlug, () => refreshEntity(entityName, projectSlug));
|
||||||
|
if (result === 'dedup') {
|
||||||
|
process.stderr.write(`(dedup: another refresh in flight)\n`);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
process.stdout.write(result ? `refreshed ${entityName}\n` : `failed to refresh ${entityName}\n`);
|
||||||
|
return result ? 0 : 1;
|
||||||
|
}
|
||||||
|
const allResult = withRefreshLock(projectSlug, () => refreshAll(projectSlug));
|
||||||
|
if (allResult === 'dedup') {
|
||||||
|
process.stderr.write(`(dedup: another refresh in flight)\n`);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
process.stdout.write(`refreshed=${allResult.success} failed=${allResult.failed}\n`);
|
||||||
|
return allResult.failed > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
case 'invalidate': {
|
||||||
|
const entityName = positional[0];
|
||||||
|
if (!entityName) { printUsage(); return 1; }
|
||||||
|
cmdInvalidate(entityName, projectSlug);
|
||||||
|
process.stdout.write(`invalidated ${entityName}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'digest': {
|
||||||
|
const slug = positional[0];
|
||||||
|
if (!slug) { printUsage(); return 1; }
|
||||||
|
const content = cmdDigest(slug);
|
||||||
|
if (content === null) {
|
||||||
|
process.stderr.write('brain unreachable or page not found\n');
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
process.stdout.write(content);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'meta': {
|
||||||
|
const meta = cmdMeta(projectSlug);
|
||||||
|
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'bootstrap': {
|
||||||
|
if (!projectSlug) {
|
||||||
|
process.stderr.write('bootstrap requires --project <slug>\n');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const draft = cmdBootstrap(projectSlug);
|
||||||
|
process.stdout.write(JSON.stringify(draft, null, 2) + '\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'list': {
|
||||||
|
const pages = cmdList(projectSlug);
|
||||||
|
if (flags.json) {
|
||||||
|
process.stdout.write(JSON.stringify(pages, null, 2) + '\n');
|
||||||
|
} else {
|
||||||
|
for (const p of pages) {
|
||||||
|
process.stdout.write(`${p.type}\t${p.slug}\t${p.title ?? ''}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'purge': {
|
||||||
|
const slug = positional[0];
|
||||||
|
if (!slug) { printUsage(); return 1; }
|
||||||
|
const result = cmdPurge(slug);
|
||||||
|
if (result.deleted) {
|
||||||
|
process.stdout.write(`deleted ${slug}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
process.stderr.write(`failed: ${result.error}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
case '':
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
printUsage();
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
process.stderr.write(`unknown subcommand: ${cmd}\n`);
|
||||||
|
printUsage();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run main when invoked as a script (not when imported by tests)
|
||||||
|
if (import.meta.main) {
|
||||||
|
main().then((code) => process.exit(code));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-codex-session-import — backfill question-log.jsonl from Codex sessions.
|
||||||
|
#
|
||||||
|
# Codex has no AskUserQuestion tool (per docs/spikes/codex-session-format.md).
|
||||||
|
# gstack skills running on Codex emit Decision Briefs as plain agent_message
|
||||||
|
# text, and the user's response shows up in the next user_message. This
|
||||||
|
# importer reconstructs those question/answer pairs from the structured
|
||||||
|
# JSONL session files at ~/.codex/sessions/<date>/.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gstack-codex-session-import # latest session under ~/.codex/sessions/
|
||||||
|
# gstack-codex-session-import <path/to.jsonl> # explicit session file
|
||||||
|
# gstack-codex-session-import --since <iso> # all sessions newer than <iso>
|
||||||
|
#
|
||||||
|
# Recovery strategy (two-tier per D5/T4 spike):
|
||||||
|
# 1. Marker-first: extract <gstack-qid:foo-bar> from agent_message → stable id.
|
||||||
|
# 2. Pattern fallback: detect D<N> header + numbered options → hash id
|
||||||
|
# (source=codex-import-pattern, never used as preference key per D18).
|
||||||
|
#
|
||||||
|
# Writes via bin/gstack-question-log so source tagging, dedup, and async
|
||||||
|
# derive all apply uniformly.
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
|
CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}"
|
||||||
|
|
||||||
|
MODE="latest"
|
||||||
|
EXPLICIT_PATH=""
|
||||||
|
SINCE_ISO=""
|
||||||
|
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
case "$1" in
|
||||||
|
--since)
|
||||||
|
MODE="since"
|
||||||
|
SINCE_ISO="${2:-}"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "unknown flag: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
MODE="explicit"
|
||||||
|
EXPLICIT_PATH="$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve list of session files to process.
|
||||||
|
SESSION_FILES=()
|
||||||
|
case "$MODE" in
|
||||||
|
explicit)
|
||||||
|
if [ ! -f "$EXPLICIT_PATH" ]; then
|
||||||
|
echo "gstack-codex-session-import: file not found: $EXPLICIT_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SESSION_FILES=("$EXPLICIT_PATH")
|
||||||
|
;;
|
||||||
|
latest)
|
||||||
|
if [ ! -d "$CODEX_SESSIONS_ROOT" ]; then
|
||||||
|
echo "NO_SESSIONS: $CODEX_SESSIONS_ROOT does not exist"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
LATEST=$(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -print 2>/dev/null \
|
||||||
|
| xargs ls -t 2>/dev/null | head -1 || true)
|
||||||
|
if [ -z "$LATEST" ]; then
|
||||||
|
echo "NO_SESSIONS: no rollout-*.jsonl files under $CODEX_SESSIONS_ROOT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
SESSION_FILES=("$LATEST")
|
||||||
|
;;
|
||||||
|
since)
|
||||||
|
if [ -z "$SINCE_ISO" ]; then
|
||||||
|
echo "--since requires an ISO 8601 timestamp" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
while IFS= read -r f; do
|
||||||
|
SESSION_FILES+=("$f")
|
||||||
|
done < <(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -newer <(date -u -d "$SINCE_ISO" 2>/dev/null || date -u) 2>/dev/null)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ${#SESSION_FILES[@]} -eq 0 ]; then
|
||||||
|
echo "NO_SESSIONS: nothing to import"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse + extract via bun. Emits one line per question found, ready to pipe
|
||||||
|
# into gstack-question-log. Tagged with source so downstream consumers
|
||||||
|
# (/plan-tune stats, dream cycle) can distinguish backfilled events from
|
||||||
|
# live captures.
|
||||||
|
IMPORTED=0
|
||||||
|
SKIPPED_NO_ANSWER=0
|
||||||
|
|
||||||
|
for SESSION_FILE in "${SESSION_FILES[@]}"; do
|
||||||
|
COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const sessionPath = process.env.SESSION_FILE_PATH;
|
||||||
|
const qlogBin = process.env.QLOG_BIN;
|
||||||
|
const lines = fs.readFileSync(sessionPath, "utf-8").trim().split("\n").filter(Boolean);
|
||||||
|
|
||||||
|
let meta = null;
|
||||||
|
const stream = [];
|
||||||
|
for (const ln of lines) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(ln);
|
||||||
|
if (e.type === "session_meta") meta = e.payload;
|
||||||
|
else stream.push(e);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!meta) {
|
||||||
|
console.error("WARN: no session_meta in " + sessionPath);
|
||||||
|
console.log("0 0");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = meta.cwd || "";
|
||||||
|
const sessionId = (meta.id || path.basename(sessionPath)).slice(0, 64);
|
||||||
|
|
||||||
|
// Walk for agent_message → next user_message pairs.
|
||||||
|
const briefs = [];
|
||||||
|
for (let i = 0; i < stream.length; i++) {
|
||||||
|
const e = stream[i];
|
||||||
|
if (e.type !== "event_msg" || e.payload?.type !== "agent_message") continue;
|
||||||
|
const text = String(e.payload?.message || "");
|
||||||
|
if (!text) continue;
|
||||||
|
// Detect D-numbered brief or marker. Markers are sufficient on their own.
|
||||||
|
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})>/i);
|
||||||
|
const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m);
|
||||||
|
if (!markerMatch && !dMatch) continue;
|
||||||
|
|
||||||
|
// Find the next user_message in the stream.
|
||||||
|
let answer = null;
|
||||||
|
for (let j = i + 1; j < stream.length; j++) {
|
||||||
|
const e2 = stream[j];
|
||||||
|
if (e2.type === "event_msg" && e2.payload?.type === "user_message") {
|
||||||
|
answer = String(e2.payload?.message || "").trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!answer) continue;
|
||||||
|
|
||||||
|
// Extract options A) ... B) ... from the brief.
|
||||||
|
const optMatches = [...text.matchAll(/^([A-Z])\)\s+(.+?)(?:\s+\(recommended\))?$/gm)];
|
||||||
|
const options = optMatches.map((m) => m[2].trim());
|
||||||
|
|
||||||
|
// Identify recommended option (label first, prose fallback).
|
||||||
|
let recommended;
|
||||||
|
const recLabel = [...text.matchAll(/^([A-Z])\)\s+(.+?)\s+\(recommended\)$/gm)];
|
||||||
|
if (recLabel.length === 1) recommended = recLabel[0][2].trim();
|
||||||
|
|
||||||
|
// Identify which option the user picked from their answer.
|
||||||
|
// Look for "A" / "A) ..." / option-label prefix match.
|
||||||
|
let userChoice = "__unknown__";
|
||||||
|
const letterMatch = answer.match(/^\s*([A-Z])\b/);
|
||||||
|
if (letterMatch) {
|
||||||
|
const idx = letterMatch[1].charCodeAt(0) - 65;
|
||||||
|
if (idx >= 0 && idx < options.length) userChoice = options[idx];
|
||||||
|
else userChoice = letterMatch[1];
|
||||||
|
} else if (options.length > 0) {
|
||||||
|
const lower = answer.toLowerCase();
|
||||||
|
const m = options.find((o) => lower.includes(o.toLowerCase().slice(0, 12)));
|
||||||
|
if (m) userChoice = m;
|
||||||
|
}
|
||||||
|
if (userChoice === "__unknown__") {
|
||||||
|
userChoice = answer.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = (dMatch?.[1] || text.split("\n")[0]).slice(0, 200);
|
||||||
|
|
||||||
|
let questionId, source;
|
||||||
|
if (markerMatch) {
|
||||||
|
questionId = markerMatch[1];
|
||||||
|
source = "codex-import-marker";
|
||||||
|
} else {
|
||||||
|
const sortedOpts = [...options].sort().join("|");
|
||||||
|
const h = crypto.createHash("sha1").update("codex::" + summary + "::" + sortedOpts).digest("hex").slice(0, 10);
|
||||||
|
questionId = "hook-" + h;
|
||||||
|
source = "codex-import-pattern";
|
||||||
|
}
|
||||||
|
|
||||||
|
briefs.push({
|
||||||
|
skill: "codex",
|
||||||
|
question_id: questionId,
|
||||||
|
question_summary: summary,
|
||||||
|
options_count: options.length || 1,
|
||||||
|
user_choice: userChoice.slice(0, 64),
|
||||||
|
...(recommended ? { recommended: recommended.slice(0, 64) } : {}),
|
||||||
|
source,
|
||||||
|
session_id: sessionId,
|
||||||
|
// Use ts_nanos+ts shape from the event itself if available; else null.
|
||||||
|
ts: e.timestamp || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
for (const b of briefs) {
|
||||||
|
const res = spawnSync(qlogBin, [JSON.stringify(b)], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
// Run from the originating cwd so gstack-slug bucks events into the
|
||||||
|
// right project. Falls back to the importer cwd if the session cwd
|
||||||
|
// no longer exists.
|
||||||
|
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
if (res.status === 0) imported++;
|
||||||
|
}
|
||||||
|
console.log(imported + " 0");
|
||||||
|
' 2>&1)
|
||||||
|
|
||||||
|
IMP=$(echo "$COUNT_LINE" | awk "{print \$1}")
|
||||||
|
IMPORTED=$((IMPORTED + IMP))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "IMPORTED: $IMPORTED events from ${#SESSION_FILES[@]} session(s)"
|
||||||
|
|
@ -8,11 +8,13 @@
|
||||||
# gstack-config defaults — show just the defaults table
|
# gstack-config defaults — show just the defaults table
|
||||||
#
|
#
|
||||||
# Env overrides (for testing):
|
# Env overrides (for testing):
|
||||||
|
# GSTACK_STATE_ROOT — override ~/.gstack state directory (highest priority,
|
||||||
|
# matches D16 cathedral isolation convention)
|
||||||
# GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts)
|
# GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts)
|
||||||
# GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat)
|
# GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
STATE_DIR="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}"
|
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}"
|
||||||
CONFIG_FILE="$STATE_DIR/config.yaml"
|
CONFIG_FILE="$STATE_DIR/config.yaml"
|
||||||
|
|
||||||
# Annotated header for new config files. Written once on first `set`.
|
# Annotated header for new config files. Written once on first `set`.
|
||||||
|
|
@ -73,6 +75,16 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||||
# # Set to true once the privacy gate has asked the user.
|
# # Set to true once the privacy gate has asked the user.
|
||||||
# # Flip back to false to be re-prompted.
|
# # Flip back to false to be re-prompted.
|
||||||
#
|
#
|
||||||
|
# ─── Plan-tune hooks ─────────────────────────────────────────────────
|
||||||
|
# plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune
|
||||||
|
# # Claude Code hooks (PostToolUse capture +
|
||||||
|
# # PreToolUse preference enforcement).
|
||||||
|
# # prompt — ask on a real TTY, skip otherwise (default)
|
||||||
|
# # yes — install non-interactively
|
||||||
|
# # no — skip non-interactively
|
||||||
|
# # Override per-run: ./setup --plan-tune-hooks /
|
||||||
|
# # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS.
|
||||||
|
#
|
||||||
# ─── Advanced ────────────────────────────────────────────────────────
|
# ─── Advanced ────────────────────────────────────────────────────────
|
||||||
# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship
|
# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship
|
||||||
# gstack_contributor: false # true = file field reports when gstack misbehaves
|
# gstack_contributor: false # true = file field reports when gstack misbehaves
|
||||||
|
|
@ -108,19 +120,145 @@ lookup_default() {
|
||||||
cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt
|
cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt
|
||||||
artifacts_sync_mode) echo "off" ;;
|
artifacts_sync_mode) echo "off" ;;
|
||||||
artifacts_sync_mode_prompted) echo "false" ;;
|
artifacts_sync_mode_prompted) echo "false" ;;
|
||||||
|
plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install
|
||||||
|
|
||||||
|
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||||
|
redact_prepush_hook) echo "false" ;;
|
||||||
|
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
||||||
|
# brain_trust_policy@<hash> — unset on fresh install; setup-gbrain
|
||||||
|
# writes 'personal' for local engines,
|
||||||
|
# asks the user for remote-ambiguous.
|
||||||
|
# salience_allowlist — empty falls through to
|
||||||
|
# SALIENCE_DEFAULT_ALLOWLIST (D9).
|
||||||
|
# user_slug_at_<hash> — empty triggers resolve-user-slug
|
||||||
|
# fallback chain (D4 A3) on first call.
|
||||||
|
brain_trust_policy*) echo "unset" ;;
|
||||||
|
salience_allowlist) echo "" ;;
|
||||||
|
user_slug_at_*) echo "" ;;
|
||||||
*) echo "" ;;
|
*) echo "" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Brain-integration helpers (T5+T10+T16)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Compute sha8 of a string. Used for endpoint hashing.
|
||||||
|
sha8_of() {
|
||||||
|
printf '%s' "$1" | shasum -a 256 | cut -c1-8
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain
|
||||||
|
# MCP server URL. Falls back to the literal 'local' when no MCP is configured.
|
||||||
|
endpoint_hash() {
|
||||||
|
_claude_json="$HOME/.claude.json"
|
||||||
|
if [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
_url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null)
|
||||||
|
if [ -n "$_url" ] && [ "$_url" != "null" ]; then
|
||||||
|
sha8_of "$_url"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf '%s' "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect endpoint hash collisions. When two distinct endpoints share the same
|
||||||
|
# sha8 prefix (rare but possible), escalate to sha16 by emitting the longer
|
||||||
|
# hash. Detection: scan config file for existing brain_trust_policy@<hash> or
|
||||||
|
# user_slug_at_<hash> keys; if any non-active hash equals the active sha8 but
|
||||||
|
# would differ at sha16, the active endpoint needs sha16.
|
||||||
|
endpoint_hash_with_collision_check() {
|
||||||
|
_active=$(endpoint_hash)
|
||||||
|
if [ "$_active" = "local" ]; then
|
||||||
|
printf '%s' "$_active"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# If a different endpoint (different URL) shares this sha8, escalate.
|
||||||
|
# We only catch this when the config has another endpoint recorded.
|
||||||
|
_matching=$(grep -E "^(brain_trust_policy|user_slug_at)@${_active}" "$CONFIG_FILE" 2>/dev/null | head -1 || true)
|
||||||
|
_claude_json="$HOME/.claude.json"
|
||||||
|
if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
_url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null)
|
||||||
|
_sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16)
|
||||||
|
# Look for any sha16-namespaced key that conflicts. If a stored sha16 exists
|
||||||
|
# and differs from current sha16, that's the collision evidence; emit sha16.
|
||||||
|
_stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true)
|
||||||
|
if [ -n "$_stored16" ]; then
|
||||||
|
printf '%s' "$_sha16"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf '%s' "$_active"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the user-slug per D4 A3 chain:
|
||||||
|
# 1. mcp__gbrain__whoami.client_name (best effort via gbrain CLI shell-out)
|
||||||
|
# 2. $USER env
|
||||||
|
# 3. sha8($(git config user.email))
|
||||||
|
# 4. anonymous-<sha8(hostname)>
|
||||||
|
# Persists result via gstack-config set user_slug_at_<endpoint-hash> on first call.
|
||||||
|
resolve_user_slug() {
|
||||||
|
_hash=$(endpoint_hash_with_collision_check)
|
||||||
|
_stored=$(grep -E "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||||
|
if [ -n "$_stored" ]; then
|
||||||
|
printf '%s' "$_stored"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_slug=""
|
||||||
|
|
||||||
|
# Layer 1: gbrain whoami
|
||||||
|
if command -v gbrain >/dev/null 2>&1; then
|
||||||
|
_whoami=$(gbrain whoami --json 2>/dev/null || true)
|
||||||
|
if [ -n "$_whoami" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
_client_name=$(printf '%s' "$_whoami" | jq -r '.client_name // .token_name // empty' 2>/dev/null || true)
|
||||||
|
if [ -n "$_client_name" ] && [ "$_client_name" != "null" ]; then
|
||||||
|
_slug=$(printf '%s' "$_client_name" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Layer 2: $USER
|
||||||
|
if [ -z "$_slug" ] && [ -n "${USER:-}" ]; then
|
||||||
|
_slug=$(printf '%s' "$USER" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Layer 3: sha8 of git email
|
||||||
|
if [ -z "$_slug" ]; then
|
||||||
|
_email=$(git config user.email 2>/dev/null || true)
|
||||||
|
if [ -n "$_email" ]; then
|
||||||
|
_slug="email-$(sha8_of "$_email")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Layer 4: anonymous-<sha8(hostname)>
|
||||||
|
if [ -z "$_slug" ]; then
|
||||||
|
_slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist via direct file write (avoid recursion into gstack-config set)
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||||
|
echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$_slug"
|
||||||
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
get)
|
get)
|
||||||
KEY="${2:?Usage: gstack-config get <key>}"
|
KEY="${2:?Usage: gstack-config get <key>}"
|
||||||
# Validate key (alphanumeric + underscore only)
|
# Validate key (alphanumeric + underscore + optional @<hash> suffix for
|
||||||
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then
|
# endpoint-namespaced keys introduced by the brain-aware planning layer)
|
||||||
echo "Error: key must contain only alphanumeric characters and underscores" >&2
|
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
|
||||||
|
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
# Use literal match for keys containing @ (sha hashes), regex otherwise
|
||||||
|
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||||
if [ -z "$VALUE" ]; then
|
if [ -z "$VALUE" ]; then
|
||||||
VALUE=$(lookup_default "$KEY")
|
VALUE=$(lookup_default "$KEY")
|
||||||
fi
|
fi
|
||||||
|
|
@ -129,11 +267,17 @@ case "${1:-}" in
|
||||||
set)
|
set)
|
||||||
KEY="${2:?Usage: gstack-config set <key> <value>}"
|
KEY="${2:?Usage: gstack-config set <key> <value>}"
|
||||||
VALUE="${3:?Usage: gstack-config set <key> <value>}"
|
VALUE="${3:?Usage: gstack-config set <key> <value>}"
|
||||||
# Validate key (alphanumeric + underscore only)
|
# Validate key (alphanumeric + underscore + optional @<hash> suffix)
|
||||||
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then
|
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
|
||||||
echo "Error: key must contain only alphanumeric characters and underscores" >&2
|
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Validate brain_trust_policy value domain (D4 / D11)
|
||||||
|
if printf '%s' "$KEY" | grep -qE '^brain_trust_policy(@|$)' && \
|
||||||
|
[ "$VALUE" != "personal" ] && [ "$VALUE" != "shared" ] && [ "$VALUE" != "unset" ]; then
|
||||||
|
echo "Warning: brain_trust_policy '$VALUE' not recognized. Valid values: personal, shared, unset. Using unset." >&2
|
||||||
|
VALUE="unset"
|
||||||
|
fi
|
||||||
# V1: whitelist values for keys with closed value domains. Unknown values warn + default.
|
# V1: whitelist values for keys with closed value domains. Unknown values warn + default.
|
||||||
if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then
|
if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then
|
||||||
echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2
|
echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2
|
||||||
|
|
@ -143,6 +287,21 @@ case "${1:-}" in
|
||||||
echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2
|
echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2
|
||||||
VALUE="off"
|
VALUE="off"
|
||||||
fi
|
fi
|
||||||
|
# redact_repo_visibility: a LOCAL override for repos gh/glab can't read (e.g.
|
||||||
|
# self-hosted GitLab). It lives in ~/.gstack/config.yaml (never committed), so
|
||||||
|
# it can't be used to weaken the gate repo-wide for other contributors.
|
||||||
|
if [ "$KEY" = "redact_repo_visibility" ] && [ "$VALUE" != "public" ] && [ "$VALUE" != "private" ] && [ "$VALUE" != "unknown" ]; then
|
||||||
|
echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2
|
||||||
|
VALUE="unknown"
|
||||||
|
fi
|
||||||
|
if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
|
||||||
|
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
||||||
|
VALUE="false"
|
||||||
|
fi
|
||||||
|
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
||||||
|
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||||
|
VALUE="prompt"
|
||||||
|
fi
|
||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
# Write annotated header on first creation
|
# Write annotated header on first creation
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
|
@ -172,7 +331,7 @@ case "${1:-}" in
|
||||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||||
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
||||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||||
artifacts_sync_mode artifacts_sync_mode_prompted; do
|
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
|
||||||
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||||
SOURCE="default"
|
SOURCE="default"
|
||||||
if [ -n "$VALUE" ]; then
|
if [ -n "$VALUE" ]; then
|
||||||
|
|
@ -188,12 +347,66 @@ case "${1:-}" in
|
||||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||||
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
||||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||||
artifacts_sync_mode artifacts_sync_mode_prompted; do
|
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
|
||||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||||
done
|
done
|
||||||
;;
|
;;
|
||||||
|
endpoint-hash)
|
||||||
|
# Brain integration helper (T10): print active brain endpoint sha8
|
||||||
|
endpoint_hash_with_collision_check
|
||||||
|
;;
|
||||||
|
resolve-user-slug)
|
||||||
|
# Brain integration helper (T16 / D4 A3): resolve + persist user-slug
|
||||||
|
resolve_user_slug
|
||||||
|
;;
|
||||||
|
gbrain-refresh)
|
||||||
|
# Brain integration helper: re-detect gbrain installation state and
|
||||||
|
# persist to ~/.gstack/gbrain-detection.json. gen-skill-docs reads this
|
||||||
|
# file (when invoked with --respect-detection) to decide whether to
|
||||||
|
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks in
|
||||||
|
# generated SKILL.md files.
|
||||||
|
#
|
||||||
|
# Run this after installing or uninstalling gbrain so your locally
|
||||||
|
# generated SKILL.md files match your installation state.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
|
||||||
|
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
if [ ! -x "$DETECT_BIN" ]; then
|
||||||
|
echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then
|
||||||
|
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp"
|
||||||
|
fi
|
||||||
|
mv "$DETECTION_FILE.tmp" "$DETECTION_FILE"
|
||||||
|
|
||||||
|
# Summarize for the user. Use python (already required elsewhere) to
|
||||||
|
# parse the JSON portably; fall back to grep if python is unavailable.
|
||||||
|
PYTHON_CMD=$(command -v python3 || command -v python || true)
|
||||||
|
if [ -n "$PYTHON_CMD" ]; then
|
||||||
|
STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_local_status','unknown'))" 2>/dev/null || echo unknown)
|
||||||
|
VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_version') or 'unknown')" 2>/dev/null || echo unknown)
|
||||||
|
else
|
||||||
|
STATUS=$(grep -o '"gbrain_local_status":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
VERSION=$(grep -o '"gbrain_version":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
[ -z "$STATUS" ] && STATUS=unknown
|
||||||
|
[ -z "$VERSION" ] && VERSION=unknown
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$STATUS" in
|
||||||
|
ok)
|
||||||
|
echo "Detected gbrain v$VERSION → brain-aware blocks will render in planning-skill SKILL.md files."
|
||||||
|
echo "Run 'bun run gen:skill-docs' in the gstack repo (or re-run ./setup) to regenerate now."
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: gstack-config {get|set|list|defaults} [key] [value]"
|
echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files."
|
||||||
|
echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
|
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
|
||||||
LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl"
|
LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl"
|
||||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-distill-apply — apply a single distillation proposal after user Y.
|
||||||
|
#
|
||||||
|
# Plan-tune cathedral T11. Reads distillation-proposals.json, applies the
|
||||||
|
# Nth proposal to the right surface:
|
||||||
|
#
|
||||||
|
# preference → gstack-question-preference --write
|
||||||
|
# declared-nudge → atomic update to ~/.gstack/developer-profile.json declared
|
||||||
|
# memory-nugget → append to ~/.gstack/free-text-memory.json (local fallback)
|
||||||
|
#
|
||||||
|
# Always confirm before calling this from the skill — the bin assumes the user
|
||||||
|
# already approved (Codex #15 trust boundary). The skill template (/plan-tune
|
||||||
|
# distill review section) handles the confirm UX.
|
||||||
|
#
|
||||||
|
# gbrain integration: when gbrain is configured, the skill template ALSO
|
||||||
|
# invokes mcp__gbrain__put_page / extract_facts / add_tag in the same turn
|
||||||
|
# (those are MCP tools, not CLI-callable). Pass --gbrain-published true to
|
||||||
|
# mark the proposal as mirrored to gbrain. The local file always gets the
|
||||||
|
# write so it's the durable source-of-truth even on machines without gbrain.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gstack-distill-apply --proposal <N> # apply Nth proposal
|
||||||
|
# gstack-distill-apply --proposal <N> --gbrain-published true
|
||||||
|
# gstack-distill-apply --list # show pending proposals
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
||||||
|
SLUG="${SLUG:-unknown}"
|
||||||
|
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
|
||||||
|
PROPOSAL_FILE="$PROJECT_DIR/distillation-proposals.json"
|
||||||
|
MEMORY_FILE="$GSTACK_HOME/free-text-memory.json"
|
||||||
|
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
|
||||||
|
|
||||||
|
ACTION="apply"
|
||||||
|
PROPOSAL_IDX=""
|
||||||
|
GBRAIN_PUBLISHED="false"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--proposal) PROPOSAL_IDX="$2"; shift 2 ;;
|
||||||
|
--gbrain-published) GBRAIN_PUBLISHED="$2"; shift 2 ;;
|
||||||
|
--list) ACTION="list"; shift ;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$PROPOSAL_FILE" ]; then
|
||||||
|
echo "NO_PROPOSALS: $PROPOSAL_FILE missing — run gstack-distill-free-text first"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" = "list" ]; then
|
||||||
|
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8"));
|
||||||
|
const proposals = p.proposals || [];
|
||||||
|
if (proposals.length === 0) { console.log("(no proposals)"); process.exit(0); }
|
||||||
|
console.log("GENERATED: " + p.generated_at);
|
||||||
|
console.log("SOURCE_EVENTS: " + (p.source_event_count || 0));
|
||||||
|
proposals.forEach((pr, i) => {
|
||||||
|
console.log("");
|
||||||
|
console.log("[" + i + "] " + (pr.kind || "?") + " (confidence: " + (pr.confidence || "?") + ")");
|
||||||
|
if (pr.rationale) console.log(" rationale: " + pr.rationale);
|
||||||
|
if (pr.kind === "preference") {
|
||||||
|
console.log(" question_id: " + pr.question_id);
|
||||||
|
console.log(" preference: " + pr.preference);
|
||||||
|
} else if (pr.kind === "declared-nudge") {
|
||||||
|
console.log(" dimension: " + pr.dimension);
|
||||||
|
console.log(" direction: " + pr.direction + " (" + (pr.magnitude || "?") + ")");
|
||||||
|
} else if (pr.kind === "memory-nugget") {
|
||||||
|
console.log(" nugget: " + pr.nugget);
|
||||||
|
console.log(" signal_keys: " + JSON.stringify(pr.applies_to_signal_keys || []));
|
||||||
|
}
|
||||||
|
if (pr.source_quotes && pr.source_quotes.length) {
|
||||||
|
console.log(" quotes:");
|
||||||
|
pr.source_quotes.forEach((q) => console.log(" - \"" + q + "\""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PROPOSAL_IDX" ]; then
|
||||||
|
echo "--proposal <N> required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply via bun. Each kind has its own surface.
|
||||||
|
mkdir -p "$PROJECT_DIR"
|
||||||
|
PROPOSAL_IDX="$PROPOSAL_IDX" \
|
||||||
|
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" \
|
||||||
|
MEMORY_FILE_PATH="$MEMORY_FILE" \
|
||||||
|
PROFILE_FILE_PATH="$PROFILE_FILE" \
|
||||||
|
PREF_BIN="$SCRIPT_DIR/gstack-question-preference" \
|
||||||
|
GBRAIN_PUBLISHED="$GBRAIN_PUBLISHED" \
|
||||||
|
bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const idx = parseInt(process.env.PROPOSAL_IDX, 10);
|
||||||
|
const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8"));
|
||||||
|
const proposals = p.proposals || [];
|
||||||
|
if (!Number.isInteger(idx) || idx < 0 || idx >= proposals.length) {
|
||||||
|
process.stderr.write("invalid --proposal index " + idx + " (have " + proposals.length + ")\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const pr = proposals[idx];
|
||||||
|
|
||||||
|
const stamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Memory-nugget: always write to local file (durable source-of-truth even
|
||||||
|
// when gbrain is configured — gbrain is mirror, file is canon for the
|
||||||
|
// PreToolUse hook injection path in Layer 8).
|
||||||
|
if (pr.kind === "memory-nugget") {
|
||||||
|
const memPath = process.env.MEMORY_FILE_PATH;
|
||||||
|
let mem = { nuggets: [] };
|
||||||
|
try { mem = JSON.parse(fs.readFileSync(memPath, "utf-8")); } catch {}
|
||||||
|
if (!Array.isArray(mem.nuggets)) mem.nuggets = [];
|
||||||
|
mem.nuggets.push({
|
||||||
|
nugget: pr.nugget,
|
||||||
|
applies_to_signal_keys: pr.applies_to_signal_keys || [],
|
||||||
|
applied_at: stamp,
|
||||||
|
gbrain_published: process.env.GBRAIN_PUBLISHED === "true",
|
||||||
|
source_quotes: pr.source_quotes || [],
|
||||||
|
});
|
||||||
|
const tmp = memPath + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(mem, null, 2));
|
||||||
|
fs.renameSync(tmp, memPath);
|
||||||
|
console.log("APPLIED: memory-nugget appended to " + memPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preference: route through gstack-question-preference for the user-origin
|
||||||
|
// gate + event audit trail. source=plan-tune is the allowed value since
|
||||||
|
// the user opt-in came from inside /plan-tune.
|
||||||
|
if (pr.kind === "preference") {
|
||||||
|
const res = spawnSync(process.env.PREF_BIN, [
|
||||||
|
"--write",
|
||||||
|
JSON.stringify({
|
||||||
|
question_id: pr.question_id,
|
||||||
|
preference: pr.preference,
|
||||||
|
source: "plan-tune",
|
||||||
|
free_text: (pr.source_quotes || []).join(" | ").slice(0, 300),
|
||||||
|
}),
|
||||||
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 });
|
||||||
|
if (res.status !== 0) {
|
||||||
|
process.stderr.write("preference apply failed: " + (res.stderr || res.stdout) + "\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("APPLIED: preference " + pr.question_id + " → " + pr.preference);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared-nudge: atomic update to developer-profile.json declared. Magnitude
|
||||||
|
// tiers: small=0.05, medium=0.10, large=0.15. Clamp to [0, 1].
|
||||||
|
if (pr.kind === "declared-nudge") {
|
||||||
|
const mag = { small: 0.05, medium: 0.10, large: 0.15 }[pr.magnitude || "small"] || 0.05;
|
||||||
|
const delta = pr.direction === "down" ? -mag : mag;
|
||||||
|
const profilePath = process.env.PROFILE_FILE_PATH;
|
||||||
|
let profile = {};
|
||||||
|
try { profile = JSON.parse(fs.readFileSync(profilePath, "utf-8")); } catch {}
|
||||||
|
profile.declared = profile.declared || {};
|
||||||
|
const cur = typeof profile.declared[pr.dimension] === "number" ? profile.declared[pr.dimension] : 0.5;
|
||||||
|
const next = Math.max(0, Math.min(1, cur + delta));
|
||||||
|
profile.declared[pr.dimension] = +next.toFixed(3);
|
||||||
|
profile.declared_at = stamp;
|
||||||
|
const tmp = profilePath + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(profile, null, 2));
|
||||||
|
fs.renameSync(tmp, profilePath);
|
||||||
|
console.log("APPLIED: declared." + pr.dimension + " " + cur + " → " + profile.declared[pr.dimension]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the proposal as applied so /plan-tune list shows it consumed.
|
||||||
|
pr.applied_at = stamp;
|
||||||
|
pr.gbrain_published = process.env.GBRAIN_PUBLISHED === "true";
|
||||||
|
const tmp = process.env.PROPOSAL_FILE_PATH + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(p, null, 2));
|
||||||
|
fs.renameSync(tmp, process.env.PROPOSAL_FILE_PATH);
|
||||||
|
'
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-distill-free-text — Layer 8 "dream cycle" batch distiller.
|
||||||
|
#
|
||||||
|
# Reads auq-other free-text events from this project's question-log.jsonl,
|
||||||
|
# sends them to Claude via the Anthropic SDK, and writes structured proposals
|
||||||
|
# the user can review via /plan-tune distill. Proposals require explicit
|
||||||
|
# user Y before applying — never autonomous (Codex #15 trust boundary).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# gstack-distill-free-text # sync, prompts at end
|
||||||
|
# gstack-distill-free-text --background # spawn detached; results
|
||||||
|
# # surface on next /plan-tune
|
||||||
|
# gstack-distill-free-text --dry-run # show prompt, no API call
|
||||||
|
# gstack-distill-free-text --status # show last-run stats
|
||||||
|
#
|
||||||
|
# No rate cap — the natural rate of free-text events (rare; user has to type
|
||||||
|
# "Other" then content) bounds this loop already. Each Haiku call is ~$0.01,
|
||||||
|
# so even a runaway at one-per-minute would be ~$14/day worst case. The
|
||||||
|
# cumulative cost log at $GSTACK_STATE_ROOT/distill-cost.jsonl gives full
|
||||||
|
# auditability via --status when you want it.
|
||||||
|
# Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY.
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
||||||
|
SLUG="${SLUG:-unknown}"
|
||||||
|
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
|
||||||
|
LOG_FILE="$PROJECT_DIR/question-log.jsonl"
|
||||||
|
PROPOSAL_FILE="$PROJECT_DIR/distillation-proposals.json"
|
||||||
|
COST_LOG="$GSTACK_HOME/distill-cost.jsonl"
|
||||||
|
mkdir -p "$PROJECT_DIR"
|
||||||
|
|
||||||
|
MODE="sync"
|
||||||
|
case "${1:-}" in
|
||||||
|
--background) MODE="background" ;;
|
||||||
|
--dry-run) MODE="dry-run" ;;
|
||||||
|
--status) MODE="status" ;;
|
||||||
|
--help|-h)
|
||||||
|
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
'') ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Status subcommand --------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$MODE" = "status" ]; then
|
||||||
|
COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const slug = process.env.SLUG_PATH;
|
||||||
|
const path = process.env.COST_LOG_PATH;
|
||||||
|
if (!fs.existsSync(path)) { console.log("no distill runs yet"); process.exit(0); }
|
||||||
|
const lines = fs.readFileSync(path, "utf-8").trim().split("\n").filter(Boolean);
|
||||||
|
const mine = lines.map((l) => JSON.parse(l)).filter((e) => e.slug === slug);
|
||||||
|
if (mine.length === 0) { console.log("no distill runs yet for slug=" + slug); process.exit(0); }
|
||||||
|
const totalUsd = mine.reduce((a, e) => a + (e.cost_usd_est || 0), 0);
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
const today = mine.filter((e) => (e.ts || "").startsWith(todayIso));
|
||||||
|
const todayUsd = today.reduce((a, e) => a + (e.cost_usd_est || 0), 0);
|
||||||
|
console.log("RUNS: " + mine.length);
|
||||||
|
console.log("TODAY: " + today.length + " run(s), $" + todayUsd.toFixed(4));
|
||||||
|
console.log("ESTIMATED_TOTAL_USD: $" + totalUsd.toFixed(4));
|
||||||
|
const last = mine[mine.length - 1];
|
||||||
|
console.log("LAST_RUN: " + (last.ts || "?") + " | " + (last.proposals_count || 0) + " proposals");
|
||||||
|
'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Background mode: detach + invoke self synchronously ---------------
|
||||||
|
|
||||||
|
if [ "$MODE" = "background" ]; then
|
||||||
|
nohup "$0" >/dev/null 2>&1 &
|
||||||
|
echo "DISTILL_SPAWNED: pid=$!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No rate cap. Natural input rate (free-text events are rare) + Haiku price
|
||||||
|
# (~$0.01/run) keep this bounded. Use --status to audit spend.
|
||||||
|
|
||||||
|
# --- Gather unprocessed auq-other events from this project -------------
|
||||||
|
|
||||||
|
if [ ! -f "$LOG_FILE" ]; then
|
||||||
|
echo "NO_LOG: no question-log.jsonl in $PROJECT_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").filter(Boolean);
|
||||||
|
const out = [];
|
||||||
|
for (const l of lines) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(l);
|
||||||
|
if (e.source === "auq-other" && !e.distilled_at && e.free_text) {
|
||||||
|
out.push({
|
||||||
|
ts: e.ts,
|
||||||
|
question_id: e.question_id,
|
||||||
|
question_summary: e.question_summary,
|
||||||
|
free_text: e.free_text,
|
||||||
|
session_id: e.session_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(out));
|
||||||
|
')
|
||||||
|
|
||||||
|
EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);')
|
||||||
|
if [ "$EVENT_COUNT" -eq 0 ]; then
|
||||||
|
echo "NO_FREE_TEXT: nothing to distill"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Build distill prompt ---------------------------------------------
|
||||||
|
|
||||||
|
# Heredoc into temp file (avoids $(cat <<'PROMPT'...) which choked the
|
||||||
|
# bash parser on apostrophes elsewhere in the script).
|
||||||
|
DISTILL_PROMPT_FILE=$(mktemp)
|
||||||
|
trap 'rm -f "$DISTILL_PROMPT_FILE"' EXIT
|
||||||
|
cat > "$DISTILL_PROMPT_FILE" <<'PROMPT'
|
||||||
|
You are gstack dream-cycle distiller. Below are free-text responses the
|
||||||
|
user typed into AskUserQuestion prompts (option "Other") across recent gstack
|
||||||
|
sessions. For each response, extract structured signal that should update the
|
||||||
|
user plan-tune profile or preferences.
|
||||||
|
|
||||||
|
Return strict JSON with this shape:
|
||||||
|
{
|
||||||
|
"proposals": [
|
||||||
|
{
|
||||||
|
"kind": "preference" | "declared-nudge" | "memory-nugget",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"source_quotes": ["<verbatim quote 1>", "<verbatim quote 2>"],
|
||||||
|
"question_id": "<id>",
|
||||||
|
"preference": "never-ask" | "always-ask" | "ask-only-for-one-way",
|
||||||
|
"dimension": "scope_appetite | risk_tolerance | detail_preference | autonomy | architecture_care",
|
||||||
|
"direction": "up | down",
|
||||||
|
"magnitude": "small | medium | large",
|
||||||
|
"rationale": "<one sentence>",
|
||||||
|
"nugget": "<one-line memory>",
|
||||||
|
"applies_to_signal_keys": ["scope-appetite", "..."]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Reject any proposal where confidence < 0.7.
|
||||||
|
- Quote VERBATIM from the user free_text. Never paraphrase a source quote.
|
||||||
|
- A single user response may produce multiple proposals.
|
||||||
|
- If nothing meaningful to extract, return {"proposals": []}.
|
||||||
|
- No commentary outside the JSON.
|
||||||
|
PROMPT
|
||||||
|
DISTILL_PROMPT=$(cat "$DISTILL_PROMPT_FILE")
|
||||||
|
|
||||||
|
# --- Dry-run: emit prompt + events, exit ------------------------------
|
||||||
|
|
||||||
|
if [ "$MODE" = "dry-run" ]; then
|
||||||
|
echo "=== DISTILL PROMPT ==="
|
||||||
|
echo "$DISTILL_PROMPT"
|
||||||
|
echo
|
||||||
|
echo "=== EVENTS ($EVENT_COUNT) ==="
|
||||||
|
echo "$EVENTS_JSON" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- SDK call: fail-loud on missing key -------------------------------
|
||||||
|
|
||||||
|
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||||
|
cat <<EOF >&2
|
||||||
|
gstack-distill-free-text: ANTHROPIC_API_KEY not set.
|
||||||
|
|
||||||
|
Dream-cycle distillation needs an API key for the SDK call. Set
|
||||||
|
ANTHROPIC_API_KEY in your environment, or run with --dry-run to see
|
||||||
|
what would be sent without actually calling.
|
||||||
|
|
||||||
|
Note: this is a separate billing/auth surface from your interactive
|
||||||
|
Claude Code session (per Codex correction in D6).
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the SDK call in bun. Emits JSON: {proposals_count, cost_usd_est}.
|
||||||
|
RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
|
||||||
|
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" LOG_FILE_PATH="$LOG_FILE" \
|
||||||
|
ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||||
|
bun --cwd "$ROOT_DIR" -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const Anthropic = require("@anthropic-ai/sdk").default;
|
||||||
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||||
|
|
||||||
|
const events = JSON.parse(process.env.EVENTS_JSON);
|
||||||
|
const prompt = process.env.DISTILL_PROMPT + "\n\nFREE-TEXT RESPONSES (JSON array):\n" + JSON.stringify(events, null, 2);
|
||||||
|
|
||||||
|
// Pricing (Haiku 4.5 — cheap, fast, sufficient for structured extraction).
|
||||||
|
// Per token, USD: input $0.001/1k = 1e-6, output $0.005/1k = 5e-6.
|
||||||
|
const INPUT_PER_TOKEN = 1e-6;
|
||||||
|
const OUTPUT_PER_TOKEN = 5e-6;
|
||||||
|
|
||||||
|
const resp = await client.messages.create({
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
max_tokens: 4096,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = resp.content.map((b) => (b.type === "text" ? b.text : "")).join("");
|
||||||
|
|
||||||
|
// Strip optional fenced code blocks the model may wrap JSON in.
|
||||||
|
const stripped = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(stripped); } catch (e) {
|
||||||
|
process.stderr.write("DISTILL: model returned non-JSON: " + text.slice(0, 200) + "\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposals = Array.isArray(parsed.proposals) ? parsed.proposals : [];
|
||||||
|
// Keep only proposals with confidence >= 0.7 (model is told this rule;
|
||||||
|
// double-check in case it slipped).
|
||||||
|
const filtered = proposals.filter((p) => typeof p.confidence === "number" && p.confidence >= 0.7);
|
||||||
|
|
||||||
|
// Write proposals file (overwrite — only the latest run is reviewable).
|
||||||
|
fs.writeFileSync(process.env.PROPOSAL_FILE_PATH, JSON.stringify({
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
source_event_count: events.length,
|
||||||
|
proposals: filtered,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
// Mark source events as distilled_at so they do not re-propose.
|
||||||
|
// Update question-log.jsonl in place: read all, rewrite with distilled_at
|
||||||
|
// set on the matching events. Match by ts + question_id.
|
||||||
|
const logPath = process.env.LOG_FILE_PATH;
|
||||||
|
const distilledAt = new Date().toISOString();
|
||||||
|
const matchKeys = new Set(events.map((e) => (e.ts || "") + "::" + (e.question_id || "")));
|
||||||
|
const lines = fs.readFileSync(logPath, "utf-8").split("\n");
|
||||||
|
const out = [];
|
||||||
|
for (const ln of lines) {
|
||||||
|
if (!ln.trim()) { out.push(ln); continue; }
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(ln);
|
||||||
|
const key = (e.ts || "") + "::" + (e.question_id || "");
|
||||||
|
if (matchKeys.has(key)) {
|
||||||
|
e.distilled_at = distilledAt;
|
||||||
|
out.push(JSON.stringify(e));
|
||||||
|
} else {
|
||||||
|
out.push(ln);
|
||||||
|
}
|
||||||
|
} catch { out.push(ln); }
|
||||||
|
}
|
||||||
|
fs.writeFileSync(logPath, out.join("\n"));
|
||||||
|
|
||||||
|
// Cost estimate from usage tokens.
|
||||||
|
const usage = resp.usage || {};
|
||||||
|
const inTok = usage.input_tokens || 0;
|
||||||
|
const outTok = usage.output_tokens || 0;
|
||||||
|
const cost = inTok * INPUT_PER_TOKEN + outTok * OUTPUT_PER_TOKEN;
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
proposals_count: filtered.length,
|
||||||
|
rejected_low_confidence: proposals.length - filtered.length,
|
||||||
|
input_tokens: inTok,
|
||||||
|
output_tokens: outTok,
|
||||||
|
cost_usd_est: cost,
|
||||||
|
}));
|
||||||
|
')
|
||||||
|
|
||||||
|
# Append cost log line.
|
||||||
|
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
echo "{\"ts\":\"$TS\",\"slug\":\"$SLUG\",$(echo "$RESULT" | sed 's/^{//; s/}$//')}" >> "$COST_LOG"
|
||||||
|
|
||||||
|
echo "DISTILL_COMPLETE:"
|
||||||
|
echo " proposals_file: $PROPOSAL_FILE"
|
||||||
|
echo " $RESULT"
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
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);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||||
|
|
||||||
INPUT="$1"
|
INPUT="$1"
|
||||||
|
|
@ -49,12 +50,48 @@ if (!j.skill || !/^[a-z0-9-]+\$/.test(j.skill)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required: question_id (kebab-case, <=64 chars)
|
// Required: question_id (kebab-case, <=64 chars).
|
||||||
|
// Cathedral T5: hook-sourced events use 'hook-<10-char-hash>' which is
|
||||||
|
// kebab-case-compatible and passes the same regex.
|
||||||
if (!j.question_id || !/^[a-z0-9-]+\$/.test(j.question_id) || j.question_id.length > 64) {
|
if (!j.question_id || !/^[a-z0-9-]+\$/.test(j.question_id) || j.question_id.length > 64) {
|
||||||
process.stderr.write('gstack-question-log: invalid question_id, must be kebab-case <=64 chars\n');
|
process.stderr.write('gstack-question-log: invalid question_id, must be kebab-case <=64 chars\n');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: source — tags which writer produced this event.
|
||||||
|
// 'agent' (default) — preamble-driven write from inside the running agent
|
||||||
|
// 'hook' — PostToolUse hook captured it deterministically (T5)
|
||||||
|
// 'auq-other' — user picked 'Other' and typed free text (Layer 8)
|
||||||
|
// 'auto-decided' — PreToolUse enforcement hook substituted the answer (T6)
|
||||||
|
// 'codex-import-marker' / 'codex-import-pattern' — T9 backfill from Codex
|
||||||
|
const ALLOWED_SOURCES = ['agent', 'hook', 'auq-other', 'auto-decided', 'codex-import-marker', 'codex-import-pattern'];
|
||||||
|
if (j.source !== undefined) {
|
||||||
|
if (!ALLOWED_SOURCES.includes(j.source)) {
|
||||||
|
process.stderr.write('gstack-question-log: invalid source, must be one of: ' + ALLOWED_SOURCES.join(', ') + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
j.source = 'agent';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: tool_use_id — Claude Code hook stdin field; used for dedup.
|
||||||
|
if (j.tool_use_id !== undefined) {
|
||||||
|
if (typeof j.tool_use_id !== 'string' || j.tool_use_id.length > 128) {
|
||||||
|
process.stderr.write('gstack-question-log: tool_use_id must be string <=128 chars\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: free_text — sanitize (no newlines, <=300 chars).
|
||||||
|
if (j.free_text !== undefined) {
|
||||||
|
if (typeof j.free_text !== 'string') {
|
||||||
|
process.stderr.write('gstack-question-log: free_text must be string\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (j.free_text.length > 300) j.free_text = j.free_text.slice(0, 300);
|
||||||
|
j.free_text = j.free_text.replace(/\n+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
// Required: question_summary (non-empty, <=200 chars, no newlines)
|
// Required: question_summary (non-empty, <=200 chars, no newlines)
|
||||||
if (typeof j.question_summary !== 'string' || !j.question_summary.length) {
|
if (typeof j.question_summary !== 'string' || !j.question_summary.length) {
|
||||||
process.stderr.write('gstack-question-log: question_summary required\n');
|
process.stderr.write('gstack-question-log: question_summary required\n');
|
||||||
|
|
@ -164,7 +201,49 @@ if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$VALIDATED" >> "$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
|
LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
|
||||||
|
|
||||||
|
# Cathedral T5: composite-source dedup. If this exact (source, tool_use_id)
|
||||||
|
# was already logged within the last 100 lines, skip — protects against
|
||||||
|
# hook + agent both writing the same fire (D3 plan-tune cathedral decision).
|
||||||
|
# Lookup is bounded so the bin stays cheap on hot paths.
|
||||||
|
DEDUP_SKIP=""
|
||||||
|
if [ -f "$LOG_FILE" ]; then
|
||||||
|
DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const j = JSON.parse(process.env.VALIDATED_JSON);
|
||||||
|
if (!j.tool_use_id) { console.log(""); process.exit(0); }
|
||||||
|
const want = j.source + ":" + j.tool_use_id;
|
||||||
|
const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").slice(-100);
|
||||||
|
for (const ln of lines) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(ln);
|
||||||
|
if (p.source && p.tool_use_id && (p.source + ":" + p.tool_use_id) === want) {
|
||||||
|
console.log("dup");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
' 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DEDUP_SKIP" = "dup" ]; then
|
||||||
|
echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$VALIDATED" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Cathedral T5: fire-and-forget --derive so inferred dimensions stay current
|
||||||
|
# without per-event latency (D17). Sub-second op; output suppressed; never
|
||||||
|
# blocks the hook caller. Skipped via GSTACK_QUESTION_LOG_NO_DERIVE=1 for
|
||||||
|
# tests that don't want the side effect.
|
||||||
|
if [ -z "${GSTACK_QUESTION_LOG_NO_DERIVE:-}" ]; then
|
||||||
|
(
|
||||||
|
nohup "$SCRIPT_DIR/gstack-developer-profile" --derive >/dev/null 2>&1 &
|
||||||
|
) >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
# NOTE: question-log.jsonl is deliberately NOT enqueued for gbrain-sync.
|
# NOTE: question-log.jsonl is deliberately NOT enqueued for gbrain-sync.
|
||||||
# Per Codex v2 review, audit/derivation data stays local alongside the
|
# Per Codex v2 review, audit/derivation data stays local alongside the
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
||||||
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
||||||
SLUG="${SLUG:-unknown}"
|
SLUG="${SLUG:-unknown}"
|
||||||
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
|
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* gstack-redact — scan text for secrets/PII/legal content via the shared engine.
|
||||||
|
*
|
||||||
|
* Skill-facing CLI over lib/redact-engine.ts. Reads from stdin (default) or
|
||||||
|
* --from-file, scans, and prints findings as JSON (--json) or a human table.
|
||||||
|
*
|
||||||
|
* Exit codes (consumed by skill bash to gate dispatch/file/edit/commit):
|
||||||
|
* 0 clean (no HIGH, no MEDIUM)
|
||||||
|
* 2 MEDIUM present (no HIGH) — skill runs the per-finding AskUserQuestion
|
||||||
|
* 3 HIGH present — skill blocks
|
||||||
|
*
|
||||||
|
* WARN findings (tool-fence-degraded credentials) never change the exit code.
|
||||||
|
*
|
||||||
|
* Flags:
|
||||||
|
* --json Emit JSON {findings, counts, repoVisibility, oversize}
|
||||||
|
* --repo-visibility V public | private | unknown (default unknown=public-strict wording)
|
||||||
|
* --from-file PATH Read input from PATH instead of stdin
|
||||||
|
* --allowlist PATH Newline-delimited exact spans to suppress
|
||||||
|
* --self-email EMAIL Suppress this email (the invoking user's own)
|
||||||
|
* --repo-public-emails PATH Newline-delimited repo-public emails to suppress
|
||||||
|
* --auto-redact IDS Comma-separated finding ids to auto-redact;
|
||||||
|
* prints the redacted body to stdout + diff to stderr.
|
||||||
|
* --max-bytes N Override the fail-closed size cap (default 1 MiB).
|
||||||
|
*
|
||||||
|
* Security note: this is a GUARDRAIL, not airtight enforcement. A determined
|
||||||
|
* user can always bypass it (direct gh/git). It catches accidents.
|
||||||
|
*/
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import {
|
||||||
|
scan,
|
||||||
|
applyRedactions,
|
||||||
|
exitCodeFor,
|
||||||
|
type RepoVisibility,
|
||||||
|
type ScanOptions,
|
||||||
|
type Finding,
|
||||||
|
} from "../lib/redact-engine";
|
||||||
|
|
||||||
|
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
||||||
|
|
||||||
|
// ── pre-push hook install/uninstall (chains any existing hook) ────────────────
|
||||||
|
|
||||||
|
const MANAGED_MARKER = "# gstack-redact pre-push (managed)";
|
||||||
|
|
||||||
|
function hooksPath(): string {
|
||||||
|
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" });
|
||||||
|
if (r.status !== 0) {
|
||||||
|
process.stderr.write("gstack-redact: not in a git repo\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return r.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPrepushHook(): void {
|
||||||
|
const dir = hooksPath();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const hookPath = path.join(dir, "pre-push");
|
||||||
|
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
||||||
|
|
||||||
|
// If a non-managed hook exists, preserve it as pre-push.local and chain it.
|
||||||
|
if (fs.existsSync(hookPath)) {
|
||||||
|
const existing = fs.readFileSync(hookPath, "utf8");
|
||||||
|
if (existing.includes(MANAGED_MARKER)) {
|
||||||
|
process.stdout.write("gstack-redact: pre-push hook already installed.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localPath = path.join(dir, "pre-push.local");
|
||||||
|
fs.renameSync(hookPath, localPath);
|
||||||
|
fs.chmodSync(localPath, 0o755);
|
||||||
|
process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdin is single-consume: capture it once, feed both the chained hook and ours.
|
||||||
|
const wrapper = `#!/usr/bin/env bash
|
||||||
|
${MANAGED_MARKER}
|
||||||
|
set -euo pipefail
|
||||||
|
_input="$(cat)"
|
||||||
|
_local="$(git rev-parse --git-path hooks/pre-push.local)"
|
||||||
|
if [ -x "$_local" ]; then
|
||||||
|
printf '%s' "$_input" | "$_local" "$@" || exit $?
|
||||||
|
fi
|
||||||
|
printf '%s' "$_input" | bun "${prepushBin}" "$@"
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
||||||
|
fs.chmodSync(hookPath, 0o755);
|
||||||
|
process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallPrepushHook(): void {
|
||||||
|
const dir = hooksPath();
|
||||||
|
const hookPath = path.join(dir, "pre-push");
|
||||||
|
const localPath = path.join(dir, "pre-push.local");
|
||||||
|
if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) {
|
||||||
|
process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(localPath)) {
|
||||||
|
fs.renameSync(localPath, hookPath); // restore the chained original
|
||||||
|
process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n");
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(hookPath);
|
||||||
|
process.stdout.write("gstack-redact: removed managed pre-push hook.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(name);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
function flag(name: string): boolean {
|
||||||
|
return process.argv.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInput(): string {
|
||||||
|
const file = arg("--from-file");
|
||||||
|
if (file) {
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
if (st.size > MAX_STDIN_BYTES) {
|
||||||
|
// Don't even read it — fail closed at the CLI boundary.
|
||||||
|
process.stderr.write(`gstack-redact: input file too large (${st.size} bytes)\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
return fs.readFileSync(file, "utf8");
|
||||||
|
}
|
||||||
|
// stdin
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
const fd = 0;
|
||||||
|
const buf = Buffer.alloc(65536);
|
||||||
|
while (true) {
|
||||||
|
let n = 0;
|
||||||
|
try {
|
||||||
|
n = fs.readSync(fd, buf, 0, buf.length, null);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === "EAGAIN") continue;
|
||||||
|
if (e.code === "EOF") break;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (n === 0) break;
|
||||||
|
total += n;
|
||||||
|
if (total > MAX_STDIN_BYTES) {
|
||||||
|
process.stderr.write("gstack-redact: stdin too large\n");
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(buf.subarray(0, n)));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLines(path: string | undefined): string[] | undefined {
|
||||||
|
if (!path || !fs.existsSync(path)) return undefined;
|
||||||
|
return fs
|
||||||
|
.readFileSync(path, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpts(): ScanOptions {
|
||||||
|
const vis = (arg("--repo-visibility") as RepoVisibility) || "unknown";
|
||||||
|
const maxBytes = arg("--max-bytes");
|
||||||
|
return {
|
||||||
|
repoVisibility: ["public", "private", "unknown"].includes(vis) ? vis : "unknown",
|
||||||
|
allowlist: readLines(arg("--allowlist")),
|
||||||
|
selfEmail: arg("--self-email"),
|
||||||
|
repoPublicEmails: readLines(arg("--repo-public-emails")),
|
||||||
|
...(maxBytes ? { maxBytes: parseInt(maxBytes, 10) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanTable(findings: Finding[]): string {
|
||||||
|
if (!findings.length) return " (no findings)";
|
||||||
|
const rows = findings.map(
|
||||||
|
(f) =>
|
||||||
|
` ${f.severity.padEnd(6)} ${f.id.padEnd(24)} ${String(f.line).padStart(4)}:${String(
|
||||||
|
f.col,
|
||||||
|
).padEnd(3)} ${f.preview}`,
|
||||||
|
);
|
||||||
|
return rows.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
// Subcommands (positional, not flags).
|
||||||
|
const sub = process.argv[2];
|
||||||
|
if (sub === "install-prepush-hook") return installPrepushHook();
|
||||||
|
if (sub === "uninstall-prepush-hook") return uninstallPrepushHook();
|
||||||
|
|
||||||
|
const opts = buildOpts();
|
||||||
|
const input = readInput();
|
||||||
|
|
||||||
|
// Auto-redact mode: print redacted body to stdout, diff to stderr, exit 0.
|
||||||
|
const autoIds = arg("--auto-redact");
|
||||||
|
if (autoIds) {
|
||||||
|
const { body, diff, skipped } = applyRedactions(input, autoIds.split(","), opts);
|
||||||
|
process.stdout.write(body);
|
||||||
|
if (diff) process.stderr.write(diff + "\n");
|
||||||
|
if (skipped.length) {
|
||||||
|
process.stderr.write(
|
||||||
|
`\ngstack-redact: ${skipped.length} finding(s) could not be auto-redacted (structural) — edit manually:\n` +
|
||||||
|
skipped.map((f) => ` ${f.id} @ ${f.line}:${f.col}`).join("\n") +
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = scan(input, opts);
|
||||||
|
const code = exitCodeFor(result);
|
||||||
|
|
||||||
|
if (flag("--json")) {
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||||
|
} else {
|
||||||
|
const vis = result.repoVisibility.toUpperCase();
|
||||||
|
process.stdout.write(`gstack-redact scan — repo ${vis}\n`);
|
||||||
|
if (result.oversize) {
|
||||||
|
process.stdout.write(" BLOCKED — input too large to scan safely (fail-closed)\n");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(humanTable(result.findings) + "\n");
|
||||||
|
const { HIGH, MEDIUM, LOW, WARN } = result.counts;
|
||||||
|
process.stdout.write(` HIGH=${HIGH} MEDIUM=${MEDIUM} LOW=${LOW} WARN=${WARN}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* gstack-redact-prepush — git pre-push hook that scans the diff being pushed for
|
||||||
|
* HIGH-severity credentials and blocks the push on a hit.
|
||||||
|
*
|
||||||
|
* THIS IS A GUARDRAIL, NOT ENFORCEMENT. `git push --no-verify` bypasses it, as
|
||||||
|
* does `GSTACK_REDACT_PREPUSH=skip`. It catches accidental credential pushes,
|
||||||
|
* the most common real-world leak. It does NOT scan history, binary/LFS/submodule
|
||||||
|
* files, or non-added lines. History scanning is /cso's job.
|
||||||
|
*
|
||||||
|
* Git pre-push interface: refs are read from STDIN, one per line:
|
||||||
|
* <local ref> <local sha> <remote ref> <remote sha>
|
||||||
|
* We scan the ADDED lines of <remote sha>..<local sha> per ref (what's being
|
||||||
|
* pushed). Special cases:
|
||||||
|
* - remote sha all-zeroes → new branch: diff against merge-base with the
|
||||||
|
* remote's default branch (fallback: scan all commits unique to local ref).
|
||||||
|
* - local sha all-zeroes → branch delete: nothing to scan, skip.
|
||||||
|
* - force-push → remote..local still gives the net new content.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - HIGH finding in added lines → print + exit 1 (block), for public AND private.
|
||||||
|
* - MEDIUM → warn (non-blocking). LOW/WARN → silent.
|
||||||
|
* - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve).
|
||||||
|
*
|
||||||
|
* Installed/uninstalled via `gstack-redact install-prepush-hook` (see the
|
||||||
|
* gstack-redact CLI), which chains any pre-existing hook.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { scan, type Finding } from "../lib/redact-engine";
|
||||||
|
|
||||||
|
const ZERO = /^0+$/;
|
||||||
|
// The canonical empty-tree object; diffing against it yields all content as added.
|
||||||
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||||
|
|
||||||
|
function git(args: string[]): string {
|
||||||
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
return r.status === 0 ? (r.stdout ?? "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRemoteBranch(): string {
|
||||||
|
// origin/HEAD → origin/main, fall back to main/master.
|
||||||
|
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
|
||||||
|
if (sym) return sym.replace("refs/remotes/", "");
|
||||||
|
for (const b of ["origin/main", "origin/master"]) {
|
||||||
|
if (git(["rev-parse", "--verify", b]).trim()) return b;
|
||||||
|
}
|
||||||
|
return "origin/main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the added-line text for a ref update being pushed. */
|
||||||
|
function addedLinesFor(localSha: string, remoteSha: string): string {
|
||||||
|
let range: string;
|
||||||
|
if (ZERO.test(remoteSha)) {
|
||||||
|
// New branch: prefer what's unique to localSha vs the remote default branch.
|
||||||
|
// With no merge-base (e.g. no remote yet), diff against the empty tree so ALL
|
||||||
|
// branch content is scanned as added — fail-safe (scans more, never less).
|
||||||
|
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||||
|
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||||
|
} else {
|
||||||
|
// Existing branch (incl. force-push): net new content remote..local.
|
||||||
|
range = `${remoteSha}..${localSha}`;
|
||||||
|
}
|
||||||
|
// -U0: only changed lines; we keep lines starting with '+' (added), drop the
|
||||||
|
// +++ file header. Unified diff added lines start with a single '+'.
|
||||||
|
const diff = git(["diff", "--unified=0", "--no-color", range]);
|
||||||
|
const added: string[] = [];
|
||||||
|
for (const line of diff.split("\n")) {
|
||||||
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||||
|
added.push(line.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSkip(reason: string): void {
|
||||||
|
try {
|
||||||
|
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
||||||
|
const dir = path.join(home, "security");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(dir, "prepush-skip.jsonl"),
|
||||||
|
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort; never block a push because logging failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") {
|
||||||
|
logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip");
|
||||||
|
process.stderr.write("gstack-redact-prepush: skipped via GSTACK_REDACT_PREPUSH=skip\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdin = fs.readFileSync(0, "utf8");
|
||||||
|
const refs = stdin
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => l.split(/\s+/));
|
||||||
|
|
||||||
|
const allHigh: Finding[] = [];
|
||||||
|
let mediumCount = 0;
|
||||||
|
|
||||||
|
for (const [, localSha, , remoteSha] of refs) {
|
||||||
|
if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed
|
||||||
|
const added = addedLinesFor(localSha, remoteSha || "0");
|
||||||
|
if (!added.trim()) continue;
|
||||||
|
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
||||||
|
// as public-strict (HIGH blocks regardless either way).
|
||||||
|
const result = scan(added, { repoVisibility: "private" });
|
||||||
|
for (const f of result.findings) {
|
||||||
|
if (f.severity === "HIGH") allHigh.push(f);
|
||||||
|
else if (f.severity === "MEDIUM") mediumCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediumCount > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` +
|
||||||
|
"Not blocking. Review before this becomes public.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allHigh.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
||||||
|
);
|
||||||
|
for (const f of allHigh) {
|
||||||
|
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
|
||||||
|
}
|
||||||
|
process.stderr.write(
|
||||||
|
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n" +
|
||||||
|
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -1,21 +1,44 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json
|
# gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json
|
||||||
#
|
#
|
||||||
# Usage:
|
# Two shapes:
|
||||||
# gstack-settings-hook add <hook-command> # add SessionStart hook
|
#
|
||||||
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
|
# 1. Legacy (SessionStart only — used by setup --team and gstack-uninstall):
|
||||||
|
# gstack-settings-hook add <cmd> # adds SessionStart hook
|
||||||
|
# gstack-settings-hook remove <cmd> # removes matching SessionStart hook
|
||||||
|
#
|
||||||
|
# 2. Schema-aware (plan-tune cathedral T3 — supports PreToolUse + PostToolUse):
|
||||||
|
# gstack-settings-hook add-event --event <SessionStart|PreToolUse|PostToolUse> \
|
||||||
|
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
|
||||||
|
# gstack-settings-hook remove-source --source <tag>
|
||||||
|
# gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...]
|
||||||
|
# gstack-settings-hook rollback # restore latest backup
|
||||||
|
# gstack-settings-hook list-sources # show all gstack-tagged hook entries
|
||||||
|
#
|
||||||
|
# Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak.<ts>
|
||||||
|
# before mutating (Codex correction — silent settings.json mutation is wrong).
|
||||||
|
#
|
||||||
|
# Dedup: legacy `add`/`remove` dedupe by the historical `gstack-session-update`
|
||||||
|
# substring. Schema-aware `add-event` dedupes by (event, matcher, _gstack_source) so
|
||||||
|
# multiple gstack registrations (plan-tune, ...) don't collide.
|
||||||
#
|
#
|
||||||
# Requires: bun (already a gstack hard dependency)
|
|
||||||
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
HOOK_CMD="${2:-}"
|
|
||||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||||
|
|
||||||
if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then
|
if [ -z "$ACTION" ]; then
|
||||||
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
|
cat <<EOF >&2
|
||||||
|
Usage:
|
||||||
|
gstack-settings-hook add <hook-command> # legacy SessionStart add
|
||||||
|
gstack-settings-hook remove <hook-command> # legacy SessionStart remove
|
||||||
|
gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
|
||||||
|
gstack-settings-hook remove-source --source <tag>
|
||||||
|
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
|
||||||
|
gstack-settings-hook rollback
|
||||||
|
gstack-settings-hook list-sources
|
||||||
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -24,59 +47,239 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$ACTION" in
|
backup_settings() {
|
||||||
add)
|
if [ -f "$SETTINGS_FILE" ]; then
|
||||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e "
|
local ts
|
||||||
const fs = require('fs');
|
ts=$(date +%Y%m%d-%H%M%S)
|
||||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$ts"
|
||||||
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
echo "$SETTINGS_FILE.bak.$ts" > "$SETTINGS_FILE.bak-latest"
|
||||||
|
fi
|
||||||
let settings = {};
|
|
||||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
||||||
|
|
||||||
if (!settings.hooks) settings.hooks = {};
|
|
||||||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
||||||
|
|
||||||
// Dedup: check if hook command already registered
|
|
||||||
const exists = settings.hooks.SessionStart.some(entry =>
|
|
||||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update'))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
settings.hooks.SessionStart.push({
|
|
||||||
hooks: [{ type: 'command', command: hookCmd }]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmp = settingsPath + '.tmp';
|
# --- legacy SessionStart add/remove (backwards compat) -----------------
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
fs.renameSync(tmp, settingsPath);
|
case "$ACTION" in
|
||||||
" 2>/dev/null
|
add)
|
||||||
;;
|
HOOK_CMD="${2:-}"
|
||||||
remove)
|
if [ -z "$HOOK_CMD" ]; then
|
||||||
[ -f "$SETTINGS_FILE" ] || exit 1
|
echo "Usage: gstack-settings-hook add <hook-command>" >&2
|
||||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "
|
exit 1
|
||||||
const fs = require('fs');
|
fi
|
||||||
|
backup_settings
|
||||||
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
|
||||||
|
if (!settings.hooks) settings.hooks = {};
|
||||||
|
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||||||
|
const exists = settings.hooks.SessionStart.some(entry =>
|
||||||
|
entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update"))
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
settings.hooks.SessionStart.push({
|
||||||
|
hooks: [{ type: "command", command: hookCmd }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
|
fs.renameSync(tmp, settingsPath);
|
||||||
|
' 2>/dev/null
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove)
|
||||||
|
HOOK_CMD="${2:-}"
|
||||||
|
if [ -z "$HOOK_CMD" ]; then
|
||||||
|
echo "Usage: gstack-settings-hook remove <hook-command>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ -f "$SETTINGS_FILE" ] || exit 1
|
||||||
|
backup_settings
|
||||||
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
let settings = {};
|
||||||
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
|
||||||
if (settings.hooks && settings.hooks.SessionStart) {
|
if (settings.hooks && settings.hooks.SessionStart) {
|
||||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
||||||
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update')))
|
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update")))
|
||||||
);
|
);
|
||||||
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
||||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
}
|
}
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
const tmp = settingsPath + '.tmp';
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
" 2>/dev/null
|
' 2>/dev/null
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
add-event|diff-event)
|
||||||
|
EVENT=""
|
||||||
|
COMMAND=""
|
||||||
|
SOURCE=""
|
||||||
|
MATCHER=""
|
||||||
|
TIMEOUT=""
|
||||||
|
shift
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--event) EVENT="$2"; shift 2 ;;
|
||||||
|
--command) COMMAND="$2"; shift 2 ;;
|
||||||
|
--source) SOURCE="$2"; shift 2 ;;
|
||||||
|
--matcher) MATCHER="$2"; shift 2 ;;
|
||||||
|
--timeout) TIMEOUT="$2"; shift 2 ;;
|
||||||
|
*) echo "unknown flag: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$EVENT" ] || [ -z "$COMMAND" ] || [ -z "$SOURCE" ]; then
|
||||||
|
echo "add-event/diff-event require --event, --command, --source" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$EVENT" in
|
||||||
|
SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;;
|
||||||
|
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$ACTION" = "add-event" ]; then
|
||||||
|
backup_settings
|
||||||
|
fi
|
||||||
|
DIFF_ONLY=""
|
||||||
|
if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1; fi
|
||||||
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \
|
||||||
|
GSTACK_EVENT="$EVENT" \
|
||||||
|
GSTACK_COMMAND="$COMMAND" \
|
||||||
|
GSTACK_SOURCE="$SOURCE" \
|
||||||
|
GSTACK_MATCHER="$MATCHER" \
|
||||||
|
GSTACK_TIMEOUT="$TIMEOUT" \
|
||||||
|
GSTACK_DIFF_ONLY="$DIFF_ONLY" \
|
||||||
|
bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
const event = process.env.GSTACK_EVENT;
|
||||||
|
const cmd = process.env.GSTACK_COMMAND;
|
||||||
|
const source = process.env.GSTACK_SOURCE;
|
||||||
|
const matcher = process.env.GSTACK_MATCHER || "";
|
||||||
|
const timeoutRaw = process.env.GSTACK_TIMEOUT || "";
|
||||||
|
const diffOnly = process.env.GSTACK_DIFF_ONLY === "1";
|
||||||
|
|
||||||
|
let settings = {};
|
||||||
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
|
||||||
|
|
||||||
|
const before = JSON.stringify(settings, null, 2);
|
||||||
|
|
||||||
|
if (!settings.hooks) settings.hooks = {};
|
||||||
|
if (!settings.hooks[event]) settings.hooks[event] = [];
|
||||||
|
|
||||||
|
const matchesEntry = (entry) => {
|
||||||
|
const sameMatcher = (entry.matcher || "") === matcher;
|
||||||
|
const sameSource = entry._gstack_source === source;
|
||||||
|
return sameMatcher && sameSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = settings.hooks[event].find(matchesEntry);
|
||||||
|
const hookEntry = { type: "command", command: cmd };
|
||||||
|
if (timeoutRaw) {
|
||||||
|
const n = Number(timeoutRaw);
|
||||||
|
if (Number.isFinite(n) && n > 0) hookEntry.timeout = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.hooks = [hookEntry];
|
||||||
|
} else {
|
||||||
|
const newEntry = { _gstack_source: source, hooks: [hookEntry] };
|
||||||
|
if (matcher) newEntry.matcher = matcher;
|
||||||
|
settings.hooks[event].push(newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = JSON.stringify(settings, null, 2);
|
||||||
|
|
||||||
|
if (diffOnly) {
|
||||||
|
console.log("--- BEFORE");
|
||||||
|
console.log(before);
|
||||||
|
console.log("--- AFTER");
|
||||||
|
console.log(after);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, after + "\n");
|
||||||
|
fs.renameSync(tmp, settingsPath);
|
||||||
|
console.log("OK: " + event + " hook registered (source: " + source + ")");
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove-source)
|
||||||
|
SOURCE=""
|
||||||
|
shift
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--source) SOURCE="$2"; shift 2 ;;
|
||||||
|
*) echo "unknown flag: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$SOURCE" ]; then
|
||||||
|
echo "remove-source requires --source <tag>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||||
|
backup_settings
|
||||||
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
const source = process.env.GSTACK_SOURCE;
|
||||||
|
let settings = {};
|
||||||
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
|
||||||
|
if (!settings.hooks) { process.exit(0); }
|
||||||
|
let removed = 0;
|
||||||
|
for (const event of Object.keys(settings.hooks)) {
|
||||||
|
const before = settings.hooks[event].length;
|
||||||
|
settings.hooks[event] = settings.hooks[event].filter(entry => entry._gstack_source !== source);
|
||||||
|
removed += before - settings.hooks[event].length;
|
||||||
|
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
||||||
|
}
|
||||||
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
|
fs.renameSync(tmp, settingsPath);
|
||||||
|
console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source);
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
|
||||||
|
rollback)
|
||||||
|
if [ ! -f "$SETTINGS_FILE.bak-latest" ]; then
|
||||||
|
echo "rollback: no backup pointer at $SETTINGS_FILE.bak-latest" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
LATEST=$(cat "$SETTINGS_FILE.bak-latest")
|
||||||
|
if [ ! -f "$LATEST" ]; then
|
||||||
|
echo "rollback: pointer references missing backup $LATEST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$LATEST" "$SETTINGS_FILE"
|
||||||
|
echo "OK: restored $SETTINGS_FILE from $LATEST"
|
||||||
|
;;
|
||||||
|
|
||||||
|
list-sources)
|
||||||
|
[ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; }
|
||||||
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
let settings = {};
|
||||||
|
try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(0); }
|
||||||
|
const hooks = settings.hooks || {};
|
||||||
|
let any = false;
|
||||||
|
for (const event of Object.keys(hooks)) {
|
||||||
|
for (const entry of hooks[event]) {
|
||||||
|
if (entry._gstack_source) {
|
||||||
|
any = true;
|
||||||
|
console.log(event + "\t" + entry._gstack_source + "\t" + (entry.matcher || "(no matcher)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) console.log("(no gstack-tagged hooks)");
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown action: $ACTION (expected add or remove)" >&2
|
echo "Unknown action: $ACTION" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,10 @@ SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
|
||||||
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
||||||
if [ -x "$SETTINGS_HOOK" ]; then
|
if [ -x "$SETTINGS_HOOK" ]; then
|
||||||
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
|
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
|
||||||
|
# Cathedral T8 cleanup: also remove plan-tune PreToolUse + PostToolUse hooks.
|
||||||
|
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null | grep -q "removed [1-9]"; then
|
||||||
|
REMOVED+=("plan-tune cathedral hooks")
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── Remove global state ────────────────────────────────────
|
# ─── Remove global state ────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
// gstack-version-bump — deterministic version-state classifier + writer for /ship.
|
||||||
|
//
|
||||||
|
// Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The
|
||||||
|
// idempotency classification and the dual-write to VERSION + package.json are
|
||||||
|
// pure deterministic logic; running them as tested code removes the single
|
||||||
|
// worst /ship footgun — re-bumping an already-shipped branch — from prose the
|
||||||
|
// agent could skip or misread when the step lives in a lazy-loaded section.
|
||||||
|
//
|
||||||
|
// What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs
|
||||||
|
// minor/major, which may AskUserQuestion on feature signals) and the queue
|
||||||
|
// collision prompt. The slot pick itself is bin/gstack-next-version. This CLI
|
||||||
|
// only answers "what state am I in?" and "write this exact version".
|
||||||
|
//
|
||||||
|
// Subcommands:
|
||||||
|
// classify --base <branch> [--version-path <p>]
|
||||||
|
// Compares VERSION vs origin/<base>:VERSION vs package.json.version.
|
||||||
|
// Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists }
|
||||||
|
// state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED
|
||||||
|
// Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state
|
||||||
|
// the caller must handle), exit 2 on bad args / unresolvable base.
|
||||||
|
//
|
||||||
|
// write --version <X.Y.Z.W> [--version-path <p>]
|
||||||
|
// Validates the 4-digit pattern, writes VERSION + package.json.version.
|
||||||
|
// Use for the FRESH bump (or an approved queue rebump). Exit 3 on a
|
||||||
|
// half-write (VERSION written, package.json failed) so the caller knows
|
||||||
|
// drift exists; the next classify() will report DRIFT_STALE_PKG.
|
||||||
|
//
|
||||||
|
// repair [--version-path <p>]
|
||||||
|
// DRIFT_STALE_PKG path: sync package.json.version to the current VERSION
|
||||||
|
// file. No bump. Validates the VERSION pattern first.
|
||||||
|
//
|
||||||
|
// Contract: classify NEVER writes. write/repair mutate VERSION + package.json
|
||||||
|
// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer
|
||||||
|
// split so /ship composes them.
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||||
|
const DEFAULT = "0.0.0.0";
|
||||||
|
|
||||||
|
type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
|
||||||
|
|
||||||
|
function fail(msg: string, code = 2): never {
|
||||||
|
process.stderr.write(`gstack-version-bump: ${msg}\n`);
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function argVal(args: string[], flag: string): string | undefined {
|
||||||
|
const i = args.indexOf(flag);
|
||||||
|
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */
|
||||||
|
function resolveVersionPath(cwd: string, explicit?: string): string {
|
||||||
|
if (explicit) return join(cwd, explicit);
|
||||||
|
const pin = join(cwd, ".gstack", "version-path");
|
||||||
|
if (existsSync(pin)) {
|
||||||
|
const p = readFileSync(pin, "utf-8").trim();
|
||||||
|
if (p) return join(cwd, p);
|
||||||
|
}
|
||||||
|
return join(cwd, "VERSION");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVersionFile(p: string): string {
|
||||||
|
try {
|
||||||
|
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
|
||||||
|
return v || DEFAULT;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** package.json version + existence, parsed without spawning node. */
|
||||||
|
function readPkgVersion(cwd: string): { exists: boolean; version: string } {
|
||||||
|
const pkgPath = join(cwd, "package.json");
|
||||||
|
if (!existsSync(pkgPath)) return { exists: false, version: "" };
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(pkgPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return { exists: true, version: "" };
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
|
||||||
|
}
|
||||||
|
const version = (parsed as { version?: unknown })?.version;
|
||||||
|
return { exists: true, version: typeof version === "string" ? version : "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePkgVersion(cwd: string, version: string): void {
|
||||||
|
const pkgPath = join(cwd, "package.json");
|
||||||
|
const raw = readFileSync(pkgPath, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
parsed.version = version;
|
||||||
|
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseVersion(cwd: string, base: string, versionRel: string): string {
|
||||||
|
// Verify the base ref resolves, mirroring the Step 12 guard.
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
|
||||||
|
const v = out.replace(/[\r\n\s]/g, "");
|
||||||
|
return v || DEFAULT;
|
||||||
|
} catch {
|
||||||
|
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
|
||||||
|
if (current === base) {
|
||||||
|
// VERSION unchanged vs base. A diverging package.json means someone hand-edited
|
||||||
|
// package.json bypassing /ship — unsafe to guess which is authoritative.
|
||||||
|
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED";
|
||||||
|
return "FRESH";
|
||||||
|
}
|
||||||
|
// VERSION already moved past base.
|
||||||
|
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
|
||||||
|
return "ALREADY_BUMPED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdClassify(args: string[], cwd: string): void {
|
||||||
|
const base = argVal(args, "--base");
|
||||||
|
if (!base) fail("classify requires --base <branch>", 2);
|
||||||
|
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
|
||||||
|
const versionRel = argVal(args, "--version-path") ?? "VERSION";
|
||||||
|
const current = readVersionFile(versionPath);
|
||||||
|
const baseV = baseVersion(cwd, base!, versionRel);
|
||||||
|
const pkg = readPkgVersion(cwd);
|
||||||
|
const state = classifyState(current, baseV, pkg.exists, pkg.version);
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
state,
|
||||||
|
baseVersion: baseV,
|
||||||
|
currentVersion: current,
|
||||||
|
pkgVersion: pkg.version || null,
|
||||||
|
pkgExists: pkg.exists,
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
|
||||||
|
// classification itself succeeded, so exit 0. (Bad args / unresolvable base are
|
||||||
|
// the only exit-2 cases.)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdWrite(args: string[], cwd: string): void {
|
||||||
|
const version = argVal(args, "--version");
|
||||||
|
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
|
||||||
|
if (!VERSION_RE.test(version!)) {
|
||||||
|
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
|
||||||
|
}
|
||||||
|
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
|
||||||
|
writeFileSync(versionPath, version + "\n");
|
||||||
|
if (existsSync(join(cwd, "package.json"))) {
|
||||||
|
try {
|
||||||
|
writePkgVersion(cwd, version!);
|
||||||
|
} catch {
|
||||||
|
fail(
|
||||||
|
"failed to update package.json. VERSION was written but package.json is now stale. " +
|
||||||
|
"Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdRepair(args: string[], cwd: string): void {
|
||||||
|
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
|
||||||
|
const current = readVersionFile(versionPath);
|
||||||
|
if (!VERSION_RE.test(current)) {
|
||||||
|
fail(
|
||||||
|
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
|
||||||
|
"Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!existsSync(join(cwd, "package.json"))) {
|
||||||
|
fail("repair: no package.json to sync.", 2);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
writePkgVersion(cwd, current);
|
||||||
|
} catch {
|
||||||
|
fail("drift repair failed — could not update package.json.", 3);
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for unit tests (pure logic, no I/O).
|
||||||
|
export { classifyState, VERSION_RE, type State };
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const [sub, ...rest] = process.argv.slice(2);
|
||||||
|
const cwd = process.cwd();
|
||||||
|
switch (sub) {
|
||||||
|
case "classify": cmdClassify(rest, cwd); break;
|
||||||
|
case "write": cmdWrite(rest, cwd); break;
|
||||||
|
case "repair": cmdRepair(rest, cwd); break;
|
||||||
|
default:
|
||||||
|
fail("usage: gstack-version-bump <classify|write|repair> [flags]", 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -646,7 +646,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"canary","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"canary","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -649,7 +649,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"codex","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"codex","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"context-restore","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"context-restore","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -649,7 +649,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"context-save","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"context-save","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
17
cso/SKILL.md
17
cso/SKILL.md
|
|
@ -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)
|
||||||
|
|
@ -652,7 +652,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"cso","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"cso","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -883,6 +887,13 @@ INFRASTRUCTURE SURFACE
|
||||||
|
|
||||||
Scan git history for leaked credentials, check tracked `.env` files, find CI configs with inline secrets.
|
Scan git history for leaked credentials, check tracked `.env` files, find CI configs with inline secrets.
|
||||||
|
|
||||||
|
**Canonical pattern catalog.** The HIGH-tier credential prefixes the archaeology
|
||||||
|
greps below target (AKIA, ghp_, sk-ant-, sk_live_, xoxb-, `-----BEGIN ... PRIVATE
|
||||||
|
KEY-----`, etc.) are the same set `/spec`'s in-flight redaction blocks on. The full
|
||||||
|
3-tier taxonomy (HIGH credentials, MEDIUM PII/legal/internal, LOW) is generated from
|
||||||
|
and lives in `lib/redact-patterns.ts` — the single source of truth shared by the
|
||||||
|
`gstack-redact` engine, `/spec`, `/ship`, and the `/document-*` skills.
|
||||||
|
|
||||||
**Git history — known secret prefixes:**
|
**Git history — known secret prefixes:**
|
||||||
```bash
|
```bash
|
||||||
git log -p --all -S "AKIA" --diff-filter=A -- "*.env" "*.yml" "*.yaml" "*.json" "*.toml" 2>/dev/null
|
git log -p --all -S "AKIA" --diff-filter=A -- "*.env" "*.yml" "*.yaml" "*.json" "*.toml" 2>/dev/null
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,13 @@ INFRASTRUCTURE SURFACE
|
||||||
|
|
||||||
Scan git history for leaked credentials, check tracked `.env` files, find CI configs with inline secrets.
|
Scan git history for leaked credentials, check tracked `.env` files, find CI configs with inline secrets.
|
||||||
|
|
||||||
|
**Canonical pattern catalog.** The HIGH-tier credential prefixes the archaeology
|
||||||
|
greps below target (AKIA, ghp_, sk-ant-, sk_live_, xoxb-, `-----BEGIN ... PRIVATE
|
||||||
|
KEY-----`, etc.) are the same set `/spec`'s in-flight redaction blocks on. The full
|
||||||
|
3-tier taxonomy (HIGH credentials, MEDIUM PII/legal/internal, LOW) is generated from
|
||||||
|
and lives in `lib/redact-patterns.ts` — the single source of truth shared by the
|
||||||
|
`gstack-redact` engine, `/spec`, `/ship`, and the `/document-*` skills.
|
||||||
|
|
||||||
**Git history — known secret prefixes:**
|
**Git history — known secret prefixes:**
|
||||||
```bash
|
```bash
|
||||||
git log -p --all -S "AKIA" --diff-filter=A -- "*.env" "*.yml" "*.yaml" "*.json" "*.toml" 2>/dev/null
|
git log -p --all -S "AKIA" --diff-filter=A -- "*.env" "*.yml" "*.yaml" "*.json" "*.toml" 2>/dev/null
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -672,7 +672,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-consultation","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-consultation","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -653,7 +653,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-html","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-html","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -667,7 +667,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-shotgun","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"design-shotgun","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -652,7 +652,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"devex-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"devex-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
# gbrain write surfaces — what lands where, and how to verify
|
||||||
|
|
||||||
|
This doc serves two audiences:
|
||||||
|
|
||||||
|
1. **Agents**: when a planning skill renders the compact `## Brain Context
|
||||||
|
Load` or `## Save Results to Brain` blocks, those blocks reference this
|
||||||
|
doc. Read §Context Load or §Save Template here on-demand when you're
|
||||||
|
actually using gbrain. Skip entirely if `gbrain` is not on PATH.
|
||||||
|
2. **Humans**: after running a planning skill against a real brain, use
|
||||||
|
the manual-probe sections to confirm the page actually landed.
|
||||||
|
|
||||||
|
## What lands where
|
||||||
|
|
||||||
|
| Host + detection state | What renders in the planning-skill SKILL.md |
|
||||||
|
|---|---|
|
||||||
|
| Any host + `gstack-config gbrain-refresh` reports `gbrain_local_status: "ok"` | Compressed brain-aware blocks render. Agent reads this doc on-demand when it actually saves. ~250 token overhead per planning skill. |
|
||||||
|
| Any host + gbrain not detected | Blocks suppressed at gen-time. Zero token overhead. Calibration takes still render (separate resolver, host-agnostic). |
|
||||||
|
| GBrain or Hermes host | Blocks always render regardless of detection — these hosts ship gbrain integration as a first-class concern. |
|
||||||
|
|
||||||
|
`.gbrain-source` pins **reads** only — writes go to the default engine
|
||||||
|
configured in `~/.gbrain/config.json`. Documented at
|
||||||
|
`bin/gstack-gbrain-sync.ts` for code-lookup resolvers; gstack treats the
|
||||||
|
same contract as load-bearing for artifact `put` semantics. If a user
|
||||||
|
reports writes landing in the wrong source, look here first.
|
||||||
|
|
||||||
|
Trust policy (`personal` vs `shared`, per endpoint hash) gates auto-push
|
||||||
|
and writeback. Set via `gstack-config set
|
||||||
|
brain_trust_policy@<endpoint-hash> personal`. Local PGLite installs
|
||||||
|
auto-default to `personal`; remote-MCP installs prompt during
|
||||||
|
`/setup-gbrain` step 9.5.
|
||||||
|
|
||||||
|
## §Context Load (agent reads this when running a planning skill)
|
||||||
|
|
||||||
|
Before starting, search the brain for relevant context:
|
||||||
|
|
||||||
|
1. **Extract 2-4 keywords** from the user's request. Pick nouns, error
|
||||||
|
names, file paths, technical terms — NOT verbs or adjectives.
|
||||||
|
Example: for "the login page is broken after deploy", search for
|
||||||
|
`login broken deploy`.
|
||||||
|
2. **Search**: `gbrain search "<keyword1 keyword2>"`. Returns lines like
|
||||||
|
`[slug] Title (score: 0.85) - first line of content...`.
|
||||||
|
3. **If few results** (under 3): broaden to the single most specific
|
||||||
|
keyword and search again. If still few, proceed without brain context.
|
||||||
|
4. **Read top 3 results**: `gbrain get_page "<slug>"` for each. Stop
|
||||||
|
after 3 — diminishing returns past that.
|
||||||
|
5. **Use the context** to inform your analysis. Cite specific slugs in
|
||||||
|
your output when a brain page changed your thinking.
|
||||||
|
|
||||||
|
If `gbrain search` returns any non-zero exit (gbrain not on PATH, network
|
||||||
|
flake, throttle), treat as transient: proceed without brain context. Do
|
||||||
|
not retry inline — the user can re-run the skill later.
|
||||||
|
|
||||||
|
## §Save Template (agent reads this when actually saving)
|
||||||
|
|
||||||
|
After completing the skill, save the output. The compact resolver block
|
||||||
|
already shows the slug prefix + title + tag for your specific skill (e.g.
|
||||||
|
`gbrain put "ceo-plans/<feature-slug>" ...`). The full template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gbrain put "<slug-prefix>/<feature-slug>" --content "$(cat <<'EOF'
|
||||||
|
---
|
||||||
|
title: "<Title>: <feature name>"
|
||||||
|
tags: [<tag>, <feature-slug>]
|
||||||
|
---
|
||||||
|
<skill output in markdown — the actual deliverable, not a summary>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slug guidance**: `<feature-slug>` should be kebab-case, lowercase, and
|
||||||
|
unique within the prefix. Prefer concrete project/feature names over
|
||||||
|
abstract labels. Example: `auth-rate-limit` not `security-fix`.
|
||||||
|
|
||||||
|
**Title guidance**: the constant prefix (e.g. "CEO Plan", "Eng Review")
|
||||||
|
is fixed; the suffix is the human-readable name of the feature/topic.
|
||||||
|
|
||||||
|
**Tag guidance**: the first tag is the constant `<tag>` from the skill's
|
||||||
|
metadata (e.g. `ceo-plan`, `eng-review`). The second tag is the
|
||||||
|
`<feature-slug>` so cross-page traversal works. Add more tags if obvious
|
||||||
|
relationships exist (e.g. `[ceo-plan, auth-rate-limit, security]`).
|
||||||
|
|
||||||
|
### Entity-stub enrichment
|
||||||
|
|
||||||
|
After saving the main page, extract person and organization names
|
||||||
|
mentioned in the output. For each one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if a page exists first
|
||||||
|
gbrain search "<entity name>"
|
||||||
|
|
||||||
|
# If no match, create a stub
|
||||||
|
gbrain put "entities/<entity-slug>" --content "$(cat <<'EOF'
|
||||||
|
---
|
||||||
|
title: "<Person or Company Name>"
|
||||||
|
tags: [entity, person]
|
||||||
|
---
|
||||||
|
Stub page. Mentioned in <skill name> output. Replace with real bio when relevant.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Only extract real names** — actual person names (e.g. "Garry Tan") and
|
||||||
|
company/organization names (e.g. "Y Combinator"). Skip product names,
|
||||||
|
feature names, section headings, technical terms (CSS class names,
|
||||||
|
function names), and file paths. When in doubt, skip.
|
||||||
|
|
||||||
|
`tags: [entity, person]` for people, `tags: [entity, organization]` for
|
||||||
|
companies/teams.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- **Throttle**: exit code 1 with stderr containing `throttle`, `rate
|
||||||
|
limit`, `capacity`, or `busy`. Defer the save and move on — the brain
|
||||||
|
is busy; the content isn't lost, just not persisted this run.
|
||||||
|
- **Any other non-zero exit**: treat as transient failure. Do not retry
|
||||||
|
inline — the user can re-run the skill or run
|
||||||
|
`gstack-config gbrain-refresh` if they suspect gbrain itself is
|
||||||
|
misconfigured.
|
||||||
|
- **`gbrain: command not found`**: gbrain isn't on PATH. The compact
|
||||||
|
resolver block told you to skip — you shouldn't reach this code. If
|
||||||
|
you somehow did, silently skip and continue.
|
||||||
|
|
||||||
|
### Backlinks
|
||||||
|
|
||||||
|
If your save output mentions another brain page by name or topic, add a
|
||||||
|
backlink line at the bottom of the markdown body:
|
||||||
|
|
||||||
|
```
|
||||||
|
Related: [[other-page-slug]], [[another-slug]]
|
||||||
|
```
|
||||||
|
|
||||||
|
gbrain auto-resolves `[[slug]]` syntax into a clickable link in the
|
||||||
|
rendered page. Add backlinks only when the relationship is concrete
|
||||||
|
(e.g. "this CEO plan depends on the eng review at
|
||||||
|
`eng-reviews/auth-rate-limit`"). Don't fabricate connections.
|
||||||
|
|
||||||
|
### Completion summary
|
||||||
|
|
||||||
|
In your final skill output, note brain utilization in one line:
|
||||||
|
"Brain: read 3 pages, saved 1 page, enriched 2 entity stubs, 0 throttles."
|
||||||
|
This helps the user see brain coverage growing over time.
|
||||||
|
|
||||||
|
## Persistence verification (automated)
|
||||||
|
|
||||||
|
The matched-pair "is the data we hope to save actually being saved?"
|
||||||
|
question is covered by `test/skill-e2e-gbrain-roundtrip-local.test.ts`:
|
||||||
|
real `gbrain init --pglite` + `gbrain put` + `gbrain get` round-trip
|
||||||
|
against an isolated temp HOME. Periodic-tier. Skips when
|
||||||
|
`VOYAGE_API_KEY` is unset or gbrain CLI is missing from PATH.
|
||||||
|
|
||||||
|
Run it before opening a PR that touches the resolver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EVALS=1 EVALS_TIER=periodic VOYAGE_API_KEY=$VOYAGE_API_KEY \
|
||||||
|
bun test test/skill-e2e-gbrain-roundtrip-local.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
If you do want to spot-check by hand against your own brain after a
|
||||||
|
real planning-skill run (debugging a specific page that the agent
|
||||||
|
should have saved):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gbrain get "<prefix>/<slug>" # expect markdown + frontmatter
|
||||||
|
gbrain search "<slug fragment>" # expect slug in top results
|
||||||
|
gbrain sources list # confirm gstack-brain-<user> source
|
||||||
|
gbrain get "entities/<person>" # expect stub per named person
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote / Supabase / thin-client-MCP routing
|
||||||
|
|
||||||
|
The resolver emits a single CLI shape — `gbrain put "<slug>" --content
|
||||||
|
"..."` — that works against every engine gbrain supports. The CLI
|
||||||
|
internally routes to local PGLite, remote Supabase, or a remote MCP
|
||||||
|
endpoint depending on the user's `~/.gbrain/config.json`. **gstack
|
||||||
|
doesn't test that routing**: the storage layer is gbrain's contract to
|
||||||
|
honor, and the same CLI invocation we test against local PGLite is the
|
||||||
|
one that fires against any other engine.
|
||||||
|
|
||||||
|
If you're on Supabase or thin-client MCP and writes aren't landing:
|
||||||
|
|
||||||
|
1. `gbrain doctor --fast --json` — engine health check. If anything
|
||||||
|
reports `error`, fix that first.
|
||||||
|
2. `gstack-config get brain_trust_policy@<endpoint-hash>` must be
|
||||||
|
`personal` for auto-write. Run `gstack-config endpoint-hash` to get
|
||||||
|
the active hash. If `shared`, the agent prompts before writes — if
|
||||||
|
you declined, re-run the skill.
|
||||||
|
3. If trust policy is `personal` and `gbrain doctor` is clean but the
|
||||||
|
page still isn't there, file an issue against gbrain — gstack's
|
||||||
|
CLI call shape is the same as what T11 (`gbrain-roundtrip-local`)
|
||||||
|
exercises.
|
||||||
|
|
||||||
|
## What's NOT verified by automation
|
||||||
|
|
||||||
|
- **Calibration takes (`takes_add`)**: today these fall back to
|
||||||
|
fence-block writes inside a `gbrain put` because
|
||||||
|
`BRAIN_CALIBRATION_WRITEBACK` is FALSE pending gbrain v0.42+ shipping
|
||||||
|
the `takes_add` MCP op. When the flag flips, re-run the probe in this
|
||||||
|
doc against `/office-hours` and confirm `gbrain takes_list` surfaces a
|
||||||
|
`kind=bet` entry with the expected weight (0.9 for office-hours, per
|
||||||
|
`scripts/brain-cache-spec.ts:151-157`).
|
||||||
|
- **Per-skill E2E for the other 4 planning skills**: only `/office-hours`
|
||||||
|
has fake-CLI E2E coverage (`test/skill-e2e-office-hours-brain-writeback.test.ts`).
|
||||||
|
The resolver unit test (`test/resolvers-gbrain-save-results.test.ts`)
|
||||||
|
covers wiring for all 5. Per-skill E2E expansion is tracked in TODOS.md.
|
||||||
|
- **`.gbrain-source` write semantics**: gstack treats the documented
|
||||||
|
reads-only contract as load-bearing, but doesn't independently verify
|
||||||
|
that gbrain CLI never re-routes writes based on the pin. If you find a
|
||||||
|
case where it does, that's a gbrain bug to file upstream.
|
||||||
|
|
@ -33,6 +33,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||||
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. |
|
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. |
|
||||||
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. |
|
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. |
|
||||||
| [`/plan-tune`](#plan-tune) | **Question Tuner** | Self-tune AskUserQuestion sensitivity per question. Mark questions as never-ask, always-ask, or only-for-one-way. |
|
| [`/plan-tune`](#plan-tune) | **Question Tuner** | Self-tune AskUserQuestion sensitivity per question. Mark questions as never-ask, always-ask, or only-for-one-way. |
|
||||||
|
| [`/spec`](#spec) | **Spec Author** | Turn vague intent into a precise, executable spec in five phases. Files a GitHub issue, optionally spawns a Claude Code agent in a fresh worktree, and lets `/ship` close the source issue on merge. |
|
||||||
| [`/learn`](#learn) | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns and preferences. |
|
| [`/learn`](#learn) | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns and preferences. |
|
||||||
| [`/context-save`](#context-save) | **Save State** | Save working context (git state, decisions, remaining work) so any future session can resume. |
|
| [`/context-save`](#context-save) | **Save State** | Save working context (git state, decisions, remaining work) so any future session can resume. |
|
||||||
| [`/context-restore`](#context-restore) | **Restore State** | Resume from a saved context, even across Conductor workspace handoffs. |
|
| [`/context-restore`](#context-restore) | **Restore State** | Resume from a saved context, even across Conductor workspace handoffs. |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Spike: Claude Code hook mutation for plan-tune cathedral
|
||||||
|
|
||||||
|
**Status:** complete (2026-05-27)
|
||||||
|
**Surfaces:** D10 (does PreToolUse allow mutating AUQ input?), D19/Codex (matcher must cover MCP variants)
|
||||||
|
**Downstream consumers:** T3, T5, T6, T8
|
||||||
|
|
||||||
|
## Question this spike answers
|
||||||
|
|
||||||
|
Can a PreToolUse hook on `AskUserQuestion` actually substitute the user's
|
||||||
|
answer via `updatedInput`? If yes, what's the exact protocol?
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
**Yes.** `updatedInput` is the supported mechanism. Source:
|
||||||
|
https://code.claude.com/docs/en/hooks (confirmed 2026-04 reference).
|
||||||
|
|
||||||
|
## Hook stdin schema (PreToolUse + PostToolUse)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "abc123",
|
||||||
|
"transcript_path": "/path/to/transcript.jsonl",
|
||||||
|
"cwd": "/current/working/dir",
|
||||||
|
"permission_mode": "default",
|
||||||
|
"effort": { "level": "medium" },
|
||||||
|
"hook_event_name": "PreToolUse",
|
||||||
|
"tool_name": "AskUserQuestion",
|
||||||
|
"tool_input": { /* tool-specific */ },
|
||||||
|
"tool_use_id": "unique-id-12345"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional in subagent context: `agent_id`, `agent_type`.
|
||||||
|
|
||||||
|
## PreToolUse hook stdout schema for `allow + updatedInput`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "allow",
|
||||||
|
"permissionDecisionReason": "auto-decided by plan-tune preference",
|
||||||
|
"updatedInput": { /* shallow-merged into original tool_input */ },
|
||||||
|
"additionalContext": "optional context for Claude"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**permissionDecision values:**
|
||||||
|
- `"allow"` — proceed, optionally with `updatedInput`
|
||||||
|
- `"deny"` — block (feedback to Claude, NOT a synthetic answer per Codex
|
||||||
|
correction in D-prefixed decisions)
|
||||||
|
- `"ask"` — escalate to user
|
||||||
|
- `"defer"` — let permission flow continue
|
||||||
|
|
||||||
|
**`updatedInput` semantics:** shallow merge of fields present in the returned
|
||||||
|
object onto the original `tool_input`. Only valid with
|
||||||
|
`permissionDecision: "allow"`. This is what lets us substitute an
|
||||||
|
auto-decided answer for `never-ask` preferences.
|
||||||
|
|
||||||
|
## Matcher schema
|
||||||
|
|
||||||
|
The `matcher` field in `~/.claude/settings.json` supports JS-regex syntax
|
||||||
|
**when it contains regex metacharacters**. A matcher with only letters/
|
||||||
|
underscores is an exact match.
|
||||||
|
|
||||||
|
To cover both native + MCP `AskUserQuestion`:
|
||||||
|
```json
|
||||||
|
"matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Conductor disables native `AskUserQuestion` via `--disallowedTools` and
|
||||||
|
routes through `mcp__conductor__AskUserQuestion` — the MCP suffix is
|
||||||
|
required for our hook to fire there.
|
||||||
|
|
||||||
|
## Multiple-hook concurrency caveat
|
||||||
|
|
||||||
|
> All matching hooks run in parallel, and identical handlers are
|
||||||
|
> deduplicated automatically.
|
||||||
|
|
||||||
|
**For our use case:**
|
||||||
|
- gstack registers exactly one PreToolUse hook and one PostToolUse hook on
|
||||||
|
AUQ-shaped tool names.
|
||||||
|
- If a user has THEIR own hook that also returns `updatedInput` on
|
||||||
|
AskUserQuestion, the merge order is undefined.
|
||||||
|
- Mitigation: document this constraint in `bin/gstack-settings-hook`
|
||||||
|
install prompt. User can detect the conflict from the diff preview before
|
||||||
|
accepting.
|
||||||
|
|
||||||
|
**`permissionDecision` precedence (when multiple hooks decide):**
|
||||||
|
`deny > ask > allow > defer` — most restrictive wins.
|
||||||
|
|
||||||
|
## Implementation hookSpecificOutput examples
|
||||||
|
|
||||||
|
**Auto-decide (PreToolUse, `never-ask` preference + non-one-way):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "allow",
|
||||||
|
"permissionDecisionReason": "plan-tune: never-ask preference on ship-test-failure-triage",
|
||||||
|
"updatedInput": {
|
||||||
|
"questions": [{ /* same as input, but with auto-selected answer */ }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pass-through (no preference, or one-way safety override):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "defer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PostToolUse capture (always):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PostToolUse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(PostToolUse hooks can also set `additionalContext` to append to the tool
|
||||||
|
result; we don't need this for v1 capture.)
|
||||||
|
|
||||||
|
## Settings.json snippet for T8 hook installer
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-preference-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-log-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hook commands take `bun` invocation under the hood; absolute paths (or
|
||||||
|
`$CLAUDE_PROJECT_DIR` substitution) are required by Claude Code's hook
|
||||||
|
runner. The hooks themselves are TypeScript files that the bash wrapper
|
||||||
|
shells into bun.
|
||||||
|
|
||||||
|
## Open questions deferred to implementation
|
||||||
|
|
||||||
|
1. **Recommended-option parsing scope.** D2 says parse `(recommended)`
|
||||||
|
label first. The label is on the option's `label` field per
|
||||||
|
AskUserQuestion Format. Implementation will need to walk `tool_input.
|
||||||
|
questions[*].options[*]` looking for the label suffix. Worked
|
||||||
|
examples: ship/SKILL.md.tmpl emits options like `"A) Fix now"
|
||||||
|
(recommended)`.
|
||||||
|
|
||||||
|
2. **Auto-decided event tagging.** When hook returns `updatedInput`, the
|
||||||
|
PostToolUse hook will see the resolved input and log a normal event.
|
||||||
|
Need an extra field on the PostToolUse payload (e.g.,
|
||||||
|
`was_auto_decided: true`) that the hook can set via session state
|
||||||
|
tracking — write a marker file in `~/.gstack/sessions/<id>/.auto-decided-<tool_use_id>`
|
||||||
|
from PreToolUse, read it from PostToolUse, delete on read.
|
||||||
|
|
||||||
|
3. **Timeout behavior.** Default hook timeout is 60s but the docs are
|
||||||
|
thin on what happens at timeout. Set explicit `timeout: 5` so the
|
||||||
|
user never waits >5s on a hook misfire. Falls back to pass-through.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- https://code.claude.com/docs/en/hooks (canonical, latest as of 2026-04)
|
||||||
|
- WebSearch results 2026-05-27
|
||||||
|
- Existing `bin/gstack-settings-hook` (SessionStart-only impl, to be
|
||||||
|
superseded by T3 schema-aware rewrite)
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# Spike: Codex session storage format for plan-tune cathedral
|
||||||
|
|
||||||
|
**Status:** complete (2026-05-27)
|
||||||
|
**Surfaces:** D5 (Codex import parses structured files, not regex)
|
||||||
|
**Downstream consumers:** T9 (gstack-codex-session-import)
|
||||||
|
|
||||||
|
## Question this spike answers
|
||||||
|
|
||||||
|
What's the actual on-disk format of Codex sessions, and how do we recover
|
||||||
|
AskUserQuestion-shaped events from it for `gstack-codex-session-import`?
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.codex/
|
||||||
|
├── auth.json # Codex auth (do not touch)
|
||||||
|
├── config.toml # User config
|
||||||
|
├── goals_1.sqlite # ~24KB, internal goals DB (not relevant)
|
||||||
|
├── logs_2.sqlite # ~16MB, structured logs (target=*, see schema)
|
||||||
|
├── history.jsonl # ~9KB, command history
|
||||||
|
└── sessions/
|
||||||
|
└── 2026/05/27/
|
||||||
|
└── rollout-<iso8601>-<uuid>.jsonl # per-session transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
Session files: one JSONL per `codex exec` or interactive session. Cwd path
|
||||||
|
embedded in the `session_meta` event. CLI version recorded.
|
||||||
|
|
||||||
|
## Session JSONL event types (measured on Garry's machine, 2026-05-27)
|
||||||
|
|
||||||
|
| type | count | meaning |
|
||||||
|
|----------------|------:|---------|
|
||||||
|
| `response_item`| 382 | model's response stream (~76%) |
|
||||||
|
| `event_msg` | 97 | high-level session events (~19%) |
|
||||||
|
| `turn_context` | 6 | per-turn context snapshot |
|
||||||
|
| `session_meta` | 6 | session header (one per session) |
|
||||||
|
|
||||||
|
### response_item subtypes
|
||||||
|
|
||||||
|
| subtype | count | meaning |
|
||||||
|
|--------------------------|------:|---------|
|
||||||
|
| `function_call` | 148 | model invoked a tool |
|
||||||
|
| `function_call_output` | 148 | tool result returned to model |
|
||||||
|
| `reasoning` | 44 | reasoning summary |
|
||||||
|
| `message` | 40 | text message (input_text or output_text) |
|
||||||
|
| `web_search_call` | 2 | web search tool call |
|
||||||
|
|
||||||
|
### event_msg subtypes
|
||||||
|
|
||||||
|
| subtype | count | meaning |
|
||||||
|
|-------------------|------:|---------|
|
||||||
|
| `token_count` | 55 | per-step token accounting |
|
||||||
|
| `agent_message` | 22 | agent's prose output |
|
||||||
|
| `user_message` | 6 | user's prose input |
|
||||||
|
| `task_started` | 6 | task start (one per top-level task) |
|
||||||
|
| `task_complete` | 6 | task complete |
|
||||||
|
| `web_search_end` | 2 | web search completion |
|
||||||
|
|
||||||
|
## Critical finding: Codex has no `AskUserQuestion` tool
|
||||||
|
|
||||||
|
Codex doesn't surface AskUserQuestion as a tool call in `response_item`
|
||||||
|
stream. Gstack skills running on Codex emit AskUserQuestion-shaped
|
||||||
|
Decision Briefs as plain prose inside `agent_message` events (the
|
||||||
|
`AskUserQuestion Format` from preamble). The user's answer comes back in
|
||||||
|
the next `user_message`.
|
||||||
|
|
||||||
|
This means importing AUQ events from Codex sessions is structurally
|
||||||
|
different from importing them from Claude Code (where they ARE
|
||||||
|
tool calls):
|
||||||
|
|
||||||
|
- **Claude Code:** hook captures structured `tool_input`/`tool_output`
|
||||||
|
for `AskUserQuestion`. Question + options + answer all separated.
|
||||||
|
- **Codex:** parser must extract from `agent_message.text` body, detect
|
||||||
|
the D-numbered Decision Brief pattern, then match against the
|
||||||
|
subsequent `user_message` for the answer.
|
||||||
|
|
||||||
|
## Recovery strategy for `gstack-codex-session-import`
|
||||||
|
|
||||||
|
**Two-tier extraction:**
|
||||||
|
|
||||||
|
1. **Marker-first (D18 mechanism).** Search `agent_message` text for the
|
||||||
|
`<gstack-qid:foo-bar>` marker. If present, we have an exact question_id
|
||||||
|
and can reliably recover. (Will work once T14 adds markers to the top
|
||||||
|
10 registry questions and Codex starts emitting them via the
|
||||||
|
host-aware preamble path.)
|
||||||
|
|
||||||
|
2. **Pattern fallback.** When no marker, parse for:
|
||||||
|
- `D<N> — <title>` line (D-number from AskUserQuestion Format)
|
||||||
|
- `Recommendation: ...` line
|
||||||
|
- Option block `A) ...`, `B) ...`, etc.
|
||||||
|
- Next `user_message` event for the chosen option label
|
||||||
|
|
||||||
|
Use this only to populate hash-based question_id (the same
|
||||||
|
`hook-<sha1(skill+text+sorted_options)[:10]>` shape Layer 1 uses on
|
||||||
|
Claude). Tagged `source: "codex-pattern-fallback"`, never used as
|
||||||
|
preference key (per D18 hash drift guidance).
|
||||||
|
|
||||||
|
## Schema we'll write to question-log.jsonl from Codex import
|
||||||
|
|
||||||
|
Per existing `bin/gstack-question-log` schema, augmented with:
|
||||||
|
- `source: "codex-import-marker"` (when qid marker found)
|
||||||
|
- `source: "codex-import-pattern"` (when fallback regex used)
|
||||||
|
- `codex_session_id` (UUID from session_meta)
|
||||||
|
- `codex_cwd` (working dir from session_meta — disambiguates project)
|
||||||
|
- `codex_ts` (timestamp from event)
|
||||||
|
|
||||||
|
## Sqlite logs_2.sqlite schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
ts_nanos INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
feedback_log_body TEXT,
|
||||||
|
module_path TEXT,
|
||||||
|
file TEXT,
|
||||||
|
line INTEGER,
|
||||||
|
thread_id TEXT,
|
||||||
|
process_uuid TEXT,
|
||||||
|
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`logs_2.sqlite` is internal telemetry, not session content. **Don't use
|
||||||
|
for AUQ extraction.** Sessions JSONL is authoritative.
|
||||||
|
|
||||||
|
## Project-slug derivation
|
||||||
|
|
||||||
|
From `session_meta.payload.cwd` — derive via the existing
|
||||||
|
`bin/gstack-slug` logic on the cwd path. Conductor worktrees have their
|
||||||
|
own slug naming convention encoded in cwd; the bin already handles this.
|
||||||
|
|
||||||
|
## Versioning safety
|
||||||
|
|
||||||
|
`session_meta.payload.cli_version` records the Codex CLI version (e.g.
|
||||||
|
`0.130.0`). When the importer encounters an unknown version, log a
|
||||||
|
warning to stderr but continue — schema additions are typically
|
||||||
|
backwards-compatible in JSONL.
|
||||||
|
|
||||||
|
If `type` or `payload.type` values change in a future version, we'll see
|
||||||
|
them as `unknown` in the importer's audit log. Add a guarded
|
||||||
|
`KNOWN_VERSIONS = ["0.130.x", "0.131.x", ...]` constant in the importer
|
||||||
|
and bump explicitly when re-testing.
|
||||||
|
|
||||||
|
## Open questions for implementation
|
||||||
|
|
||||||
|
1. **Where does Codex store the "user's answer" exactly?** Need to test
|
||||||
|
with a real `codex exec` run that triggers a Decision Brief and inspect
|
||||||
|
the next event. Likely `event_msg` of subtype `user_message` or a
|
||||||
|
`response_item` of subtype `message` with `role: "user"`. Confirm
|
||||||
|
during T9 implementation.
|
||||||
|
|
||||||
|
2. **Free-text extraction for "Other".** The Decision Brief prose
|
||||||
|
doesn't structurally separate "Other" responses from named options.
|
||||||
|
Pattern fallback will need to detect "Other: <text>" wording in the
|
||||||
|
answer. T10 (dream cycle distill) only fires on this when source is
|
||||||
|
`codex-import-marker` so we can trust the data.
|
||||||
|
|
||||||
|
3. **Conductor cwd handling.** Conductor worktrees share project state
|
||||||
|
but have distinct cwds. The import should bucket events by the
|
||||||
|
project slug, not the cwd directly, so events from sibling worktrees
|
||||||
|
accumulate into the same project view.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Live inspection of `~/.codex/sessions/2026/05/*/`
|
||||||
|
- `sqlite3 ~/.codex/logs_2.sqlite ".schema"` (2026-05-27)
|
||||||
|
- Codex CLI 0.130.0 (current at spike time)
|
||||||
|
- See also: D5 cross-model tension decision in plan file.
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -652,7 +652,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"document-generate","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"document-generate","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -1107,6 +1111,20 @@ Fix any failures before proceeding.
|
||||||
|
|
||||||
1. Stage new documentation files by name (never `git add -A` or `git add .`).
|
1. Stage new documentation files by name (never `git add -A` or `git add .`).
|
||||||
|
|
||||||
|
**Redaction scan before commit.** Generated docs frequently contain example
|
||||||
|
credentials; scan the staged doc content and block on a HIGH credential (a
|
||||||
|
live-format secret in committed docs is a leak). Example configs belong in
|
||||||
|
` ```example ` fences won't excuse a live-format secret, but the per-span
|
||||||
|
placeholder filter passes obvious docs examples (e.g. `AKIAIOSFODNN7EXAMPLE`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||||
|
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||||
|
git diff --cached --no-color | grep '^+' | sed 's/^+//' | \
|
||||||
|
~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "${REDACT_VIS:-unknown}" --json
|
||||||
|
# exit 3 (HIGH) → unstage the offending doc, remove the secret, re-stage. Do NOT commit.
|
||||||
|
```
|
||||||
|
|
||||||
2. Create a commit:
|
2. Create a commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,20 @@ Fix any failures before proceeding.
|
||||||
|
|
||||||
1. Stage new documentation files by name (never `git add -A` or `git add .`).
|
1. Stage new documentation files by name (never `git add -A` or `git add .`).
|
||||||
|
|
||||||
|
**Redaction scan before commit.** Generated docs frequently contain example
|
||||||
|
credentials; scan the staged doc content and block on a HIGH credential (a
|
||||||
|
live-format secret in committed docs is a leak). Example configs belong in
|
||||||
|
` ```example ` fences won't excuse a live-format secret, but the per-span
|
||||||
|
placeholder filter passes obvious docs examples (e.g. `AKIAIOSFODNN7EXAMPLE`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||||
|
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||||
|
git diff --cached --no-color | grep '^+' | sed 's/^+//' | \
|
||||||
|
~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "${REDACT_VIS:-unknown}" --json
|
||||||
|
# exit 3 (HIGH) → unstage the offending doc, remove the secret, re-stage. Do NOT commit.
|
||||||
|
```
|
||||||
|
|
||||||
2. Create a commit:
|
2. Create a commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"document-release","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"document-release","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -1105,7 +1109,16 @@ glab mr view -F json 2>/dev/null | python3 -c "import sys,json; print(json.load(
|
||||||
|
|
||||||
If there are any documentation debt items, suggest adding a `docs-debt` label to the PR.
|
If there are any documentation debt items, suggest adding a `docs-debt` label to the PR.
|
||||||
|
|
||||||
4. Write the updated body back:
|
4. Redaction scan-at-sink, then write the updated body back. The body is already
|
||||||
|
in a temp file (`/tmp/gstack-pr-body-$$.md`); scan THAT file before editing so
|
||||||
|
the bytes scanned are the bytes sent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||||
|
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||||
|
~/.claude/skills/gstack/bin/gstack-redact --from-file /tmp/gstack-pr-body-$$.md --repo-visibility "${REDACT_VIS:-unknown}" --json
|
||||||
|
# exit 3 (HIGH) → do NOT edit, rotate+redact; exit 2 (MEDIUM) → confirm per finding.
|
||||||
|
```
|
||||||
|
|
||||||
**If GitHub:**
|
**If GitHub:**
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -375,7 +375,16 @@ glab mr view -F json 2>/dev/null | python3 -c "import sys,json; print(json.load(
|
||||||
|
|
||||||
If there are any documentation debt items, suggest adding a `docs-debt` label to the PR.
|
If there are any documentation debt items, suggest adding a `docs-debt` label to the PR.
|
||||||
|
|
||||||
4. Write the updated body back:
|
4. Redaction scan-at-sink, then write the updated body back. The body is already
|
||||||
|
in a temp file (`/tmp/gstack-pr-body-$$.md`); scan THAT file before editing so
|
||||||
|
the bytes scanned are the bytes sent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||||
|
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||||
|
~/.claude/skills/gstack/bin/gstack-redact --from-file /tmp/gstack-pr-body-$$.md --repo-visibility "${REDACT_VIS:-unknown}" --json
|
||||||
|
# exit 3 (HIGH) → do NOT edit, rotate+redact; exit 2 (MEDIUM) → confirm per finding.
|
||||||
|
```
|
||||||
|
|
||||||
**If GitHub:**
|
**If GitHub:**
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -648,7 +648,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"health","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"health","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
|
||||||
|
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||||
|
# references this file directly.
|
||||||
|
set -e
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec bun "$HERE/question-log-hook.ts"
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* PostToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T5).
|
||||||
|
*
|
||||||
|
* Reads hook stdin JSON, extracts every AUQ question + user choice from the
|
||||||
|
* tool_input/tool_response, and writes them via gstack-question-log so the
|
||||||
|
* substrate captures fires deterministically — no agent compliance required.
|
||||||
|
*
|
||||||
|
* Triggered by ~/.claude/settings.json:
|
||||||
|
* {
|
||||||
|
* "hooks": {
|
||||||
|
* "PostToolUse": [
|
||||||
|
* {
|
||||||
|
* "matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
|
||||||
|
* "hooks": [
|
||||||
|
* { "type": "command",
|
||||||
|
* "command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-log-hook",
|
||||||
|
* "timeout": 5 }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* - Always exits 0. A failing hook MUST NOT block the user's session.
|
||||||
|
* Errors land in ~/.gstack/hook-errors.log for postmortem.
|
||||||
|
* - Spawns gstack-question-log as a subprocess; that bin handles
|
||||||
|
* validation, dedup (source+tool_use_id), async derive.
|
||||||
|
* - Marker-first question_id (`<gstack-qid:foo-bar>`), hash fallback
|
||||||
|
* (D18 progressive markers).
|
||||||
|
*
|
||||||
|
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
interface HookStdin {
|
||||||
|
session_id?: string;
|
||||||
|
hook_event_name?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
tool_use_id?: string;
|
||||||
|
tool_input?: {
|
||||||
|
questions?: Array<{
|
||||||
|
question?: string;
|
||||||
|
options?: Array<string | { label?: string; description?: string }>;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
tool_response?: unknown;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedQuestion {
|
||||||
|
question_id: string;
|
||||||
|
question_summary: string;
|
||||||
|
options_count: number;
|
||||||
|
user_choice: string;
|
||||||
|
recommended?: string;
|
||||||
|
free_text?: string;
|
||||||
|
category?: string;
|
||||||
|
door_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
|
||||||
|
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
|
||||||
|
|
||||||
|
function logHookError(msg: string): void {
|
||||||
|
try {
|
||||||
|
const stateRoot =
|
||||||
|
process.env.GSTACK_STATE_ROOT ||
|
||||||
|
process.env.GSTACK_HOME ||
|
||||||
|
path.join(os.homedir(), '.gstack');
|
||||||
|
fs.mkdirSync(stateRoot, { recursive: true });
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(stateRoot, 'hook-errors.log'),
|
||||||
|
`${new Date().toISOString()} question-log-hook: ${msg}\n`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Last-resort: swallow. Hook must not block.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStdin(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf-8');
|
||||||
|
process.stdin.on('data', (chunk) => (buf += chunk));
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
process.stdin.on('error', () => resolve(buf));
|
||||||
|
// Hard cutoff so we don't hang the user's session waiting for stdin.
|
||||||
|
setTimeout(() => resolve(buf), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashQuestionId(skill: string, question: string, options: string[]): string {
|
||||||
|
const sorted = [...options].sort().join('|');
|
||||||
|
const h = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${skill}::${question}::${sorted}`)
|
||||||
|
.digest('hex');
|
||||||
|
return `hook-${h.slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker-first id extraction. Returns the marker id (stripped of the
|
||||||
|
* <gstack-qid:...> wrapper) when present, else a hash-based hook- id.
|
||||||
|
* Per D18 progressive markers — hash ids are observed-only, never used
|
||||||
|
* as preference keys.
|
||||||
|
*/
|
||||||
|
function extractQuestionId(
|
||||||
|
skill: string,
|
||||||
|
questionText: string,
|
||||||
|
options: string[],
|
||||||
|
): { id: string; marker_present: boolean; stripped_question: string } {
|
||||||
|
const match = questionText.match(MARKER_RE);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
id: match[1],
|
||||||
|
marker_present: true,
|
||||||
|
stripped_question: questionText.replace(MARKER_RE, '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: hashQuestionId(skill, questionText, options),
|
||||||
|
marker_present: false,
|
||||||
|
stripped_question: questionText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionLabels(opts: Array<string | { label?: string; description?: string }>): string[] {
|
||||||
|
return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse "(recommended)" label-first per D2; fall back to "Recommendation: X"
|
||||||
|
* prose match; refuse (return undefined) if ambiguous.
|
||||||
|
*/
|
||||||
|
function extractRecommended(questionText: string, opts: string[]): string | undefined {
|
||||||
|
const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o));
|
||||||
|
if (labelMatches.length === 1) return labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim();
|
||||||
|
if (labelMatches.length > 1) return undefined; // ambiguous
|
||||||
|
|
||||||
|
const m = questionText.match(/Recommendation:\s*([^\n]+)/i);
|
||||||
|
if (!m) return undefined;
|
||||||
|
const recPhrase = m[1].trim();
|
||||||
|
const matchByPrefix = opts.find((o) => o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)));
|
||||||
|
return matchByPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort extraction of which option the user picked per question.
|
||||||
|
* AUQ tool_response shape varies by Claude Code variant (native vs MCP),
|
||||||
|
* and the hook stdin docs don't pin a single canonical shape. We handle
|
||||||
|
* the common cases gracefully.
|
||||||
|
*/
|
||||||
|
function extractUserChoices(
|
||||||
|
response: unknown,
|
||||||
|
questionCount: number,
|
||||||
|
): Array<{ choice: string; free_text?: string }> {
|
||||||
|
const out: Array<{ choice: string; free_text?: string }> = [];
|
||||||
|
if (!response) {
|
||||||
|
for (let i = 0; i < questionCount; i++) out.push({ choice: '__unknown__' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Shape A: { answers: [{option_label, free_text?}] }
|
||||||
|
// Shape B: { questions: [{user_answer}] }
|
||||||
|
// Shape C: { content: [...] } or array.
|
||||||
|
// We probe lazily.
|
||||||
|
const rec = response as Record<string, unknown>;
|
||||||
|
if (Array.isArray(rec.answers)) {
|
||||||
|
for (const a of rec.answers as Array<Record<string, unknown>>) {
|
||||||
|
const choice = (a.option_label || a.label || a.choice || a.answer || '__unknown__') as string;
|
||||||
|
const freeText = (a.free_text || a.other_text) as string | undefined;
|
||||||
|
out.push(freeText ? { choice, free_text: freeText } : { choice });
|
||||||
|
}
|
||||||
|
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Array.isArray(rec.questions)) {
|
||||||
|
for (const q of rec.questions as Array<Record<string, unknown>>) {
|
||||||
|
const choice = (q.user_answer || q.answer || q.choice || '__unknown__') as string;
|
||||||
|
out.push({ choice });
|
||||||
|
}
|
||||||
|
while (out.length < questionCount) out.push({ choice: '__unknown__' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Fall back: stringify and log first 100 chars to help future debugging.
|
||||||
|
for (let i = 0; i < questionCount; i++) {
|
||||||
|
out.push({ choice: `__response-shape-unknown:${JSON.stringify(response).slice(0, 80)}__` });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSkill(cwd: string | undefined): string {
|
||||||
|
// Best-effort: cwd often contains the project slug but rarely the running
|
||||||
|
// skill. Without a session-state mechanism, leave as 'unknown' — the
|
||||||
|
// skill marker (<gstack-skill:NAME>) embedded in question text per
|
||||||
|
// future plan-tune work is the durable path.
|
||||||
|
void cwd;
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnLog(payload: Record<string, unknown>, cwd?: string): void {
|
||||||
|
// Locate the bin relative to this script's directory.
|
||||||
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
// hosts/claude/hooks/ -> ../../../bin/
|
||||||
|
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||||
|
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
|
||||||
|
const res = spawnSync(bin, [JSON.stringify(payload)], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 3000,
|
||||||
|
// Run from the originating tool call's cwd so gstack-slug resolves to
|
||||||
|
// the project the user is actually in, not the hook script's location.
|
||||||
|
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||||
|
});
|
||||||
|
if (res.status !== 0) {
|
||||||
|
logHookError(`gstack-question-log exited ${res.status}: ${res.stderr || res.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const raw = await readStdin();
|
||||||
|
if (!raw.trim()) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
let stdin: HookStdin;
|
||||||
|
try {
|
||||||
|
stdin = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = stdin.tool_name || '';
|
||||||
|
if (
|
||||||
|
toolName !== 'AskUserQuestion' &&
|
||||||
|
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
||||||
|
) {
|
||||||
|
// Matcher should have filtered this out; defensive no-op.
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = stdin.tool_input?.questions || [];
|
||||||
|
if (questions.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skill = detectSkill(stdin.cwd);
|
||||||
|
const choices = extractUserChoices(stdin.tool_response, questions.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
const qText = q.question || '';
|
||||||
|
if (!qText) continue;
|
||||||
|
|
||||||
|
const opts = optionLabels(q.options || []);
|
||||||
|
const { id, stripped_question } = extractQuestionId(skill, qText, opts);
|
||||||
|
const recommended = extractRecommended(stripped_question, opts);
|
||||||
|
const summary = stripped_question.slice(0, 200);
|
||||||
|
const choice = choices[i] || { choice: '__unknown__' };
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
skill,
|
||||||
|
question_id: id,
|
||||||
|
question_summary: summary,
|
||||||
|
options_count: opts.length,
|
||||||
|
user_choice: String(choice.choice).slice(0, 64),
|
||||||
|
source: choice.free_text ? 'auq-other' : 'hook',
|
||||||
|
session_id: stdin.session_id?.slice(0, 64),
|
||||||
|
tool_use_id: stdin.tool_use_id?.slice(0, 128),
|
||||||
|
};
|
||||||
|
if (recommended) payload.recommended = recommended.slice(0, 64);
|
||||||
|
if (choice.free_text) payload.free_text = String(choice.free_text);
|
||||||
|
|
||||||
|
spawnLog(payload, stdin.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
logHookError(`main crash: ${(e as Error).message}`);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
|
||||||
|
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||||
|
# references this file directly.
|
||||||
|
set -e
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec bun "$HERE/question-preference-hook.ts"
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* PreToolUse hook for AskUserQuestion (Claude Code, plan-tune cathedral T6).
|
||||||
|
*
|
||||||
|
* Enforces never-ask / always-ask / ask-only-for-one-way preferences
|
||||||
|
* deterministically — no agent compliance required.
|
||||||
|
*
|
||||||
|
* Decision tree (per question in tool_input.questions):
|
||||||
|
* 1. Extract question_id via marker (<gstack-qid:foo-bar>). If no marker,
|
||||||
|
* enforcement is skipped for this question (D18 — hash IDs are
|
||||||
|
* observed-only, never used as preference keys).
|
||||||
|
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
|
||||||
|
* 3. Read preferences with precedence: project-local > global (D8).
|
||||||
|
* 4. Apply:
|
||||||
|
* never-ask + one-way → defer (safety override; one-way always asks).
|
||||||
|
* never-ask + two-way + marker → deny with auto-decided recommendation
|
||||||
|
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
|
||||||
|
* ask-only-for-one-way + two-way + marker → same as never-ask.
|
||||||
|
* always-ask, or no preference → defer.
|
||||||
|
*
|
||||||
|
* Why deny+reason instead of allow+updatedInput:
|
||||||
|
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
|
||||||
|
* isn't structurally pinned in Claude Code docs (spike T4 left as open
|
||||||
|
* question). `deny` with a reason that names the auto-decided option is
|
||||||
|
* conservative + reliable: the model receives the rejection feedback,
|
||||||
|
* reads the recommended option from the reason, and proceeds without
|
||||||
|
* re-firing AUQ. When the spike around input mutation lands, we can
|
||||||
|
* swap to allow+updatedInput without changing the contract.
|
||||||
|
*
|
||||||
|
* Recommended-option extraction (per D2):
|
||||||
|
* - First: (recommended) label suffix on an option.
|
||||||
|
* - Fall back: "Recommendation: X" prose match against option labels.
|
||||||
|
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
|
||||||
|
* recommendation): defer instead of silent-wrong.
|
||||||
|
*
|
||||||
|
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
|
||||||
|
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
interface HookStdin {
|
||||||
|
session_id?: string;
|
||||||
|
hook_event_name?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
tool_use_id?: string;
|
||||||
|
tool_input?: {
|
||||||
|
questions?: Array<{
|
||||||
|
question?: string;
|
||||||
|
options?: Array<string | { label?: string; description?: string }>;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})>/i;
|
||||||
|
const RECOMMENDED_LABEL_RE = /\(recommended\)\s*$/i;
|
||||||
|
|
||||||
|
function stateRoot(): string {
|
||||||
|
return (
|
||||||
|
process.env.GSTACK_STATE_ROOT ||
|
||||||
|
process.env.GSTACK_HOME ||
|
||||||
|
path.join(os.homedir(), '.gstack')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logHookError(msg: string): void {
|
||||||
|
try {
|
||||||
|
const sr = stateRoot();
|
||||||
|
fs.mkdirSync(sr, { recursive: true });
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(sr, 'hook-errors.log'),
|
||||||
|
`${new Date().toISOString()} question-preference-hook: ${msg}\n`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// last-resort swallow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStdin(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let buf = '';
|
||||||
|
process.stdin.setEncoding('utf-8');
|
||||||
|
process.stdin.on('data', (chunk) => (buf += chunk));
|
||||||
|
process.stdin.on('end', () => resolve(buf));
|
||||||
|
process.stdin.on('error', () => resolve(buf));
|
||||||
|
setTimeout(() => resolve(buf), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defer(additionalContext?: string): void {
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'defer',
|
||||||
|
};
|
||||||
|
if (additionalContext) out.additionalContext = additionalContext;
|
||||||
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deny(reason: string): void {
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: reason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonSafe(filePath: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreferenceLookup {
|
||||||
|
preference: string | undefined;
|
||||||
|
source: 'project' | 'global' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupPreference(slug: string, questionId: string): PreferenceLookup {
|
||||||
|
const sr = stateRoot();
|
||||||
|
const projectFile = path.join(sr, 'projects', slug, 'question-preferences.json');
|
||||||
|
const globalFile = path.join(sr, 'global-question-preferences.json');
|
||||||
|
|
||||||
|
const project = readJsonSafe(projectFile);
|
||||||
|
if (project && typeof project[questionId] === 'string') {
|
||||||
|
return { preference: project[questionId] as string, source: 'project' };
|
||||||
|
}
|
||||||
|
const global = readJsonSafe(globalFile);
|
||||||
|
if (global && typeof global[questionId] === 'string') {
|
||||||
|
return { preference: global[questionId] as string, source: 'global' };
|
||||||
|
}
|
||||||
|
return { preference: undefined, source: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegistryEntry {
|
||||||
|
id: string;
|
||||||
|
door_type?: 'one-way' | 'two-way';
|
||||||
|
signal_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryNugget {
|
||||||
|
nugget: string;
|
||||||
|
applies_to_signal_keys: string[];
|
||||||
|
applied_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read per-session cache first, fall back to canonical local file. Cache
|
||||||
|
* invalidates by being missing — gstack-distill-apply doesn't touch the
|
||||||
|
* cache because the canonical file is always the source-of-truth on read
|
||||||
|
* miss. Sub-1ms cache reads (D13 perf).
|
||||||
|
*/
|
||||||
|
function loadMemoryNuggets(sessionId: string | undefined): MemoryNugget[] {
|
||||||
|
const sr = stateRoot();
|
||||||
|
const canonical = path.join(sr, 'free-text-memory.json');
|
||||||
|
let nuggets: MemoryNugget[] | null = null;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const cachePath = path.join(sr, 'sessions', sessionId, 'memory-cache.json');
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
||||||
|
if (Array.isArray(cached.nuggets)) {
|
||||||
|
return cached.nuggets;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// miss → fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(fs.readFileSync(canonical, 'utf-8'));
|
||||||
|
nuggets = Array.isArray(j.nuggets) ? j.nuggets : [];
|
||||||
|
} catch {
|
||||||
|
nuggets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write through to the per-session cache so subsequent hooks on this
|
||||||
|
// session take the fast path. Best-effort; never fails the hook.
|
||||||
|
if (sessionId && nuggets) {
|
||||||
|
try {
|
||||||
|
const dir = path.join(sr, 'sessions', sessionId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'memory-cache.json'),
|
||||||
|
JSON.stringify({ nuggets, cached_at: new Date().toISOString() }, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nuggets || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a given signal_key, return up to N nuggets whose applies_to_signal_keys
|
||||||
|
* include it. Sorted by recency (most-recently-applied first), capped.
|
||||||
|
*/
|
||||||
|
function nuggetsForSignal(nuggets: MemoryNugget[], signalKey: string, max = 3): string[] {
|
||||||
|
return nuggets
|
||||||
|
.filter((n) => Array.isArray(n.applies_to_signal_keys) && n.applies_to_signal_keys.includes(signalKey))
|
||||||
|
.sort((a, b) => (b.applied_at || '').localeCompare(a.applied_at || ''))
|
||||||
|
.slice(0, max)
|
||||||
|
.map((n) => n.nugget);
|
||||||
|
}
|
||||||
|
|
||||||
|
let registryCache: Record<string, RegistryEntry> | null = null;
|
||||||
|
|
||||||
|
function loadRegistry(): Record<string, RegistryEntry> {
|
||||||
|
if (registryCache) return registryCache;
|
||||||
|
registryCache = {};
|
||||||
|
try {
|
||||||
|
// Hook lives at hosts/claude/hooks/; registry at scripts/question-registry.ts
|
||||||
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||||
|
const regPath = path.join(repoRoot, 'scripts', 'question-registry.ts');
|
||||||
|
if (!fs.existsSync(regPath)) return registryCache;
|
||||||
|
const src = fs.readFileSync(regPath, 'utf-8');
|
||||||
|
// Cheap regex extraction so the hook doesn't need to import the TS file
|
||||||
|
// (which would require bun resolving the module at hook-invocation time).
|
||||||
|
// Matches entries like:
|
||||||
|
// 'ship-test-failure-triage': {
|
||||||
|
// id: 'ship-test-failure-triage',
|
||||||
|
// ...
|
||||||
|
// door_type: 'one-way',
|
||||||
|
// signal_key: 'test-discipline',
|
||||||
|
// ...
|
||||||
|
// },
|
||||||
|
const blockRe =
|
||||||
|
/'([a-z0-9-]+)':\s*\{[^}]*?door_type:\s*'(one-way|two-way)'[^}]*?\}/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = blockRe.exec(src))) {
|
||||||
|
const [block, id, door_type] = m;
|
||||||
|
const sk = block.match(/signal_key:\s*'([a-z0-9-]+)'/);
|
||||||
|
registryCache[id] = {
|
||||||
|
id,
|
||||||
|
door_type: door_type as 'one-way' | 'two-way',
|
||||||
|
signal_key: sk ? sk[1] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logHookError(`registry load failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
return registryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionLabels(opts: Array<string | { label?: string; description?: string }>): string[] {
|
||||||
|
return opts.map((o) => (typeof o === 'string' ? o : o.label || o.description || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRecommended(
|
||||||
|
questionText: string,
|
||||||
|
opts: string[],
|
||||||
|
): { recommended: string | undefined; ambiguous: boolean } {
|
||||||
|
const labelMatches = opts.filter((o) => RECOMMENDED_LABEL_RE.test(o));
|
||||||
|
if (labelMatches.length === 1) {
|
||||||
|
return { recommended: labelMatches[0].replace(RECOMMENDED_LABEL_RE, '').trim(), ambiguous: false };
|
||||||
|
}
|
||||||
|
if (labelMatches.length > 1) return { recommended: undefined, ambiguous: true };
|
||||||
|
|
||||||
|
const m = questionText.match(/Recommendation:\s*([^\n]+)/i);
|
||||||
|
if (!m) return { recommended: undefined, ambiguous: false };
|
||||||
|
const recPhrase = m[1].trim();
|
||||||
|
const prefixMatches = opts.filter((o) =>
|
||||||
|
o.toLowerCase().startsWith(recPhrase.toLowerCase().slice(0, 12)),
|
||||||
|
);
|
||||||
|
if (prefixMatches.length === 1) return { recommended: prefixMatches[0], ambiguous: false };
|
||||||
|
if (prefixMatches.length > 1) return { recommended: undefined, ambiguous: true };
|
||||||
|
return { recommended: undefined, ambiguous: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromCwd(cwd: string | undefined): string {
|
||||||
|
// Mirror gstack-slug's basename fallback. The full slug resolver shells out
|
||||||
|
// to git, which is too expensive on a hot hook path; the basename is close
|
||||||
|
// enough for preference lookup (preferences are keyed by question_id, slug
|
||||||
|
// is just the directory bucket).
|
||||||
|
if (!cwd) return 'unknown';
|
||||||
|
return path.basename(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAutoDecided(sessionId: string | undefined, toolUseId: string | undefined): void {
|
||||||
|
if (!sessionId || !toolUseId) return;
|
||||||
|
try {
|
||||||
|
const sr = stateRoot();
|
||||||
|
const dir = path.join(sr, 'sessions', sessionId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `.auto-decided-${toolUseId}`), '');
|
||||||
|
} catch (e) {
|
||||||
|
logHookError(`markAutoDecided failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an auto-decided event directly from PreToolUse, since `deny` prevents
|
||||||
|
* the tool from running and PostToolUse never fires. Without this, /plan-tune
|
||||||
|
* Recent auto-decisions would be blind to enforcement hits.
|
||||||
|
*/
|
||||||
|
function logAutoDecided(
|
||||||
|
questionId: string,
|
||||||
|
questionSummary: string,
|
||||||
|
recommended: string,
|
||||||
|
optionsCount: number,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
toolUseId: string | undefined,
|
||||||
|
cwd: string | undefined,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const repoRoot = path.resolve(here, '..', '..', '..');
|
||||||
|
const bin = path.join(repoRoot, 'bin', 'gstack-question-log');
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
skill: 'unknown',
|
||||||
|
question_id: questionId,
|
||||||
|
question_summary: questionSummary.slice(0, 200),
|
||||||
|
options_count: optionsCount,
|
||||||
|
user_choice: recommended.slice(0, 64),
|
||||||
|
recommended: recommended.slice(0, 64),
|
||||||
|
source: 'auto-decided',
|
||||||
|
session_id: sessionId?.slice(0, 64),
|
||||||
|
tool_use_id: toolUseId?.slice(0, 128),
|
||||||
|
};
|
||||||
|
spawnSync(bin, [JSON.stringify(payload)], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 3000,
|
||||||
|
// cwd of the originating tool call so gstack-slug resolves to the
|
||||||
|
// project the user is actually in, not the hook script's location.
|
||||||
|
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logHookError(`logAutoDecided failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const raw = await readStdin();
|
||||||
|
if (!raw.trim()) {
|
||||||
|
defer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stdin: HookStdin;
|
||||||
|
try {
|
||||||
|
stdin = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
logHookError(`stdin parse failed: ${(e as Error).message}`);
|
||||||
|
defer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = stdin.tool_name || '';
|
||||||
|
if (
|
||||||
|
toolName !== 'AskUserQuestion' &&
|
||||||
|
!toolName.match(/^mcp__.+__AskUserQuestion$/)
|
||||||
|
) {
|
||||||
|
defer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = stdin.tool_input?.questions || [];
|
||||||
|
if (questions.length === 0) {
|
||||||
|
defer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multi-question AUQ, enforcement is all-or-nothing per call:
|
||||||
|
// we deny only if ALL questions have marker + never-ask + safe door type.
|
||||||
|
// Mixed cases pass through (defer) so the user still gets to answer.
|
||||||
|
const registry = loadRegistry();
|
||||||
|
const slug = slugFromCwd(stdin.cwd);
|
||||||
|
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
|
||||||
|
|
||||||
|
// Compute Layer 8 memory context inline: any nuggets matching the
|
||||||
|
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
|
||||||
|
// This applies whether we defer OR deny — gives the agent + user the
|
||||||
|
// relevant prior context either way.
|
||||||
|
const contextNuggets: string[] = [];
|
||||||
|
for (const q of questions) {
|
||||||
|
const qText = q.question || '';
|
||||||
|
const marker = qText.match(MARKER_RE);
|
||||||
|
if (!marker) continue;
|
||||||
|
const entry = registry[marker[1]];
|
||||||
|
if (!entry?.signal_key) continue;
|
||||||
|
const hits = nuggetsForSignal(memoryNuggets, entry.signal_key);
|
||||||
|
for (const h of hits) {
|
||||||
|
if (!contextNuggets.includes(h)) contextNuggets.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const memoryContext = contextNuggets.length
|
||||||
|
? '[plan-tune memory] Past answers suggest: ' + contextNuggets.join(' | ')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const autoDecisions: Array<{ id: string; recommended: string }> = [];
|
||||||
|
for (const q of questions) {
|
||||||
|
const qText = q.question || '';
|
||||||
|
const marker = qText.match(MARKER_RE);
|
||||||
|
if (!marker) {
|
||||||
|
defer(memoryContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const questionId = marker[1];
|
||||||
|
const pref = lookupPreference(slug, questionId);
|
||||||
|
if (!pref.preference || pref.preference === 'always-ask') {
|
||||||
|
defer(memoryContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = registry[questionId];
|
||||||
|
const doorType = entry?.door_type || 'two-way';
|
||||||
|
if (doorType === 'one-way') {
|
||||||
|
// Safety override — even never-ask doesn't bypass one-way doors.
|
||||||
|
defer(memoryContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = optionLabels(q.options || []);
|
||||||
|
const { recommended, ambiguous } = extractRecommended(qText, opts);
|
||||||
|
if (!recommended || ambiguous) {
|
||||||
|
// Refuse-on-ambiguous per D2 — fail safe, ask normally.
|
||||||
|
defer(memoryContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoDecisions.push({ id: questionId, recommended });
|
||||||
|
}
|
||||||
|
|
||||||
|
// All questions were eligible for enforcement.
|
||||||
|
markAutoDecided(stdin.session_id, stdin.tool_use_id);
|
||||||
|
|
||||||
|
// Log each auto-decided question now, since deny prevents PostToolUse from
|
||||||
|
// firing. /plan-tune Recent auto-decisions reads source=auto-decided events.
|
||||||
|
for (let i = 0; i < autoDecisions.length; i++) {
|
||||||
|
const d = autoDecisions[i];
|
||||||
|
const q = questions[i];
|
||||||
|
const qText = (q.question || '').replace(MARKER_RE, '').trim();
|
||||||
|
const opts = optionLabels(q.options || []);
|
||||||
|
logAutoDecided(d.id, qText, d.recommended, opts.length, stdin.session_id, stdin.tool_use_id, stdin.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonLines = autoDecisions.map(
|
||||||
|
(d) =>
|
||||||
|
`[plan-tune auto-decide] ${d.id} → ${d.recommended} (your never-ask preference). Proceed with that option without re-prompting. Change with /plan-tune.`,
|
||||||
|
);
|
||||||
|
deny(reasonLines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
logHookError(`main crash: ${(e as Error).message}`);
|
||||||
|
defer();
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -687,7 +687,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"investigate","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"investigate","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-clean","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-clean","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -652,7 +652,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -653,7 +653,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-fix","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-fix","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -656,7 +656,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-qa","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-qa","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-sync","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"ios-sync","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -645,7 +645,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"land-and-deploy","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"land-and-deploy","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -646,7 +646,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"landing-report","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"landing-report","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -648,7 +648,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"learn","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"learn","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* redact-audit-log — append-only forensic trail for the Phase 4.5a semantic
|
||||||
|
* review (D5). Records WHETHER the semantic pass marked a body clean/flagged and
|
||||||
|
* WHICH categories fired — never the body content. A body_sha256 lets a later
|
||||||
|
* investigation confirm "the pass saw this exact draft and called it clean."
|
||||||
|
*
|
||||||
|
* The file (`~/.gstack/security/semantic-reviews.jsonl`) is sensitive metadata,
|
||||||
|
* not "safe": it leaks repo names, timing, and a membership oracle via the hash.
|
||||||
|
* Written 0600. Local-only — no third-party egress.
|
||||||
|
*
|
||||||
|
* Usable two ways:
|
||||||
|
* - CLI: bun lib/redact-audit-log.ts '<json-line-without-ts/hash>' [body-file]
|
||||||
|
* (the skill passes the outcome JSON + a path to the scanned body; we
|
||||||
|
* stamp ts + body_sha256 and append.)
|
||||||
|
* - import { appendSemanticReview } from "./redact-audit-log";
|
||||||
|
*/
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
export interface SemanticReviewEntry {
|
||||||
|
ts: string;
|
||||||
|
spec_archive_path?: string;
|
||||||
|
repo_visibility: string;
|
||||||
|
outcome: "clean" | "flagged";
|
||||||
|
categories_flagged: string[];
|
||||||
|
body_sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityDir(): string {
|
||||||
|
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
||||||
|
return path.join(home, "security");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(s: string): string {
|
||||||
|
return createHash("sha256").update(s, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append one entry. Best-effort: never throws into the caller's flow. */
|
||||||
|
export function appendSemanticReview(entry: SemanticReviewEntry): void {
|
||||||
|
try {
|
||||||
|
const dir = securityDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, "semantic-reviews.jsonl");
|
||||||
|
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
||||||
|
try {
|
||||||
|
fs.chmodSync(file, 0o600);
|
||||||
|
} catch {
|
||||||
|
// chmod can fail on some filesystems; the append still happened.
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// audit log is best-effort, not the security boundary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
// Date is allowed here (CLI process, not a resumable workflow).
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const json = process.argv[2];
|
||||||
|
const bodyFile = process.argv[3];
|
||||||
|
if (!json) {
|
||||||
|
process.stderr.write(
|
||||||
|
'usage: redact-audit-log \'{"repo_visibility":"public","outcome":"flagged","categories_flagged":["legal"],"spec_archive_path":"..."}\' [body-file]\n',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let partial: Partial<SemanticReviewEntry>;
|
||||||
|
try {
|
||||||
|
partial = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
process.stderr.write("redact-audit-log: invalid JSON\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const body = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
|
||||||
|
appendSemanticReview({
|
||||||
|
ts: now(),
|
||||||
|
repo_visibility: partial.repo_visibility ?? "unknown",
|
||||||
|
outcome: partial.outcome === "flagged" ? "flagged" : "clean",
|
||||||
|
categories_flagged: partial.categories_flagged ?? [],
|
||||||
|
body_sha256: sha256(body),
|
||||||
|
...(partial.spec_archive_path ? { spec_archive_path: partial.spec_archive_path } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,479 @@
|
||||||
|
/**
|
||||||
|
* redact-engine — pure scanning + auto-redaction over the shared taxonomy.
|
||||||
|
*
|
||||||
|
* No I/O. Deterministic. The CLI shim (`bin/gstack-redact`), the pre-push hook
|
||||||
|
* (`bin/gstack-redact-prepush`), and tests all import from here.
|
||||||
|
*
|
||||||
|
* Key behaviors (locked in /plan-eng-review + two Codex passes):
|
||||||
|
* - Normalization BEFORE matching (NFKC + strip zero-width + decode a small
|
||||||
|
* set of HTML entities) so Unicode-confusable / zero-width evasion fails.
|
||||||
|
* Findings map back to ORIGINAL offsets via an index map.
|
||||||
|
* - ReDoS safety: a hard input-size cap that fails CLOSED (oversize input
|
||||||
|
* returns a single synthetic HIGH "input too large to scan safely" finding,
|
||||||
|
* so callers block rather than skip). Patterns are linear-time (lint-tested).
|
||||||
|
* - NO visibility-based tier mutation. `repoVisibility` is recorded on each
|
||||||
|
* finding (drives sterner AUQ wording in the skill) but never promotes a
|
||||||
|
* MEDIUM to HIGH. (TENSION-2-followup.)
|
||||||
|
* - Placeholder suppression is per-matched-span.
|
||||||
|
* - Tool-attributed fences (``` ```codex-review ``` / ``` ```greptile ```)
|
||||||
|
* degrade credential findings to a non-blocking WARN — UNLESS the span is a
|
||||||
|
* live-format credential the doc-example heuristic can't excuse. No nonce,
|
||||||
|
* no trust exemption (the marker scheme was dropped as theater).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
PATTERNS,
|
||||||
|
PATTERNS_BY_ID,
|
||||||
|
isPlaceholderSpan,
|
||||||
|
type RedactPattern,
|
||||||
|
type Tier,
|
||||||
|
type Category,
|
||||||
|
} from "./redact-patterns";
|
||||||
|
|
||||||
|
export type RepoVisibility = "public" | "private" | "unknown";
|
||||||
|
|
||||||
|
/** A WARN is a finding that does not block but is surfaced (tool-fence degrade). */
|
||||||
|
export type Severity = Tier | "WARN";
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string;
|
||||||
|
tier: Tier;
|
||||||
|
/** Effective severity after tool-fence degrade. HIGH/MEDIUM/LOW or WARN. */
|
||||||
|
severity: Severity;
|
||||||
|
category: Category;
|
||||||
|
description: string;
|
||||||
|
/** 1-based line in the ORIGINAL (un-normalized) text. */
|
||||||
|
line: number;
|
||||||
|
/** 1-based column in the ORIGINAL text. */
|
||||||
|
col: number;
|
||||||
|
/** Safe-masked preview (never more than 4 leading chars of the secret). */
|
||||||
|
preview: string;
|
||||||
|
/** Whether this finding offers one-keystroke auto-redact (PII subset). */
|
||||||
|
autoRedactable: boolean;
|
||||||
|
/** Repo visibility at scan time — drives sterner AUQ wording, not the tier. */
|
||||||
|
repoVisibility: RepoVisibility;
|
||||||
|
/** True when degraded to WARN because it sat in a tool-attributed fence. */
|
||||||
|
toolFenceDegraded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanOptions {
|
||||||
|
repoVisibility?: RepoVisibility;
|
||||||
|
/** Extra allowlist entries (exact strings) that suppress a matched span. */
|
||||||
|
allowlist?: string[];
|
||||||
|
/** The invoking user's own email (from `git config user.email`) — allowlisted. */
|
||||||
|
selfEmail?: string;
|
||||||
|
/**
|
||||||
|
* Emails already public in the repo (git log authors, package.json, CODEOWNERS).
|
||||||
|
* Suppressed for `pii.email` since they're not a new leak.
|
||||||
|
*/
|
||||||
|
repoPublicEmails?: string[];
|
||||||
|
/** Hard byte cap. Oversize input fails CLOSED. Default 1 MiB. */
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
findings: Finding[];
|
||||||
|
counts: { HIGH: number; MEDIUM: number; LOW: number; WARN: number };
|
||||||
|
repoVisibility: RepoVisibility;
|
||||||
|
/** True when the input-size cap tripped (caller should BLOCK). */
|
||||||
|
oversize: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
|
const EMAIL_ALLOW_DOMAINS = [/@example\.(com|org|net)$/i, /@example\.[a-z]{2,}$/i];
|
||||||
|
const EMAIL_ALLOW_LOCALPARTS = [/^noreply@/i, /^no-reply@/i, /^donotreply@/i];
|
||||||
|
|
||||||
|
// ── Normalization ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ZERO_WIDTH = /[]/g;
|
||||||
|
const HTML_ENTITIES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": '"',
|
||||||
|
"'": "'",
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize text for matching while producing an index map back to the original.
|
||||||
|
* Returns the normalized string and a function mapping a normalized offset to
|
||||||
|
* the corresponding original offset.
|
||||||
|
*
|
||||||
|
* Strategy: walk the original char-by-char, applying NFKC per char, dropping
|
||||||
|
* zero-width chars, and expanding a small fixed set of HTML entities. Each
|
||||||
|
* emitted normalized char records the original offset it came from. This keeps
|
||||||
|
* the map exact for the transformations we apply (which are all local).
|
||||||
|
*/
|
||||||
|
export function normalizeWithMap(input: string): {
|
||||||
|
normalized: string;
|
||||||
|
map: number[];
|
||||||
|
} {
|
||||||
|
const out: string[] = [];
|
||||||
|
const map: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
// HTML entity expansion (fixed small set; longest first).
|
||||||
|
let matchedEntity = false;
|
||||||
|
for (const ent in HTML_ENTITIES) {
|
||||||
|
if (input.startsWith(ent, i)) {
|
||||||
|
const rep = HTML_ENTITIES[ent];
|
||||||
|
for (const ch of rep) {
|
||||||
|
out.push(ch);
|
||||||
|
map.push(i);
|
||||||
|
}
|
||||||
|
i += ent.length;
|
||||||
|
matchedEntity = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedEntity) continue;
|
||||||
|
|
||||||
|
const ch = input[i];
|
||||||
|
if (ZERO_WIDTH.test(ch)) {
|
||||||
|
ZERO_WIDTH.lastIndex = 0;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ZERO_WIDTH.lastIndex = 0;
|
||||||
|
|
||||||
|
const norm = ch.normalize("NFKC");
|
||||||
|
for (const nch of norm) {
|
||||||
|
out.push(nch);
|
||||||
|
map.push(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
// Sentinel so an offset == length maps to the original length.
|
||||||
|
map.push(input.length);
|
||||||
|
return { normalized: out.join(""), map };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
|
||||||
|
|
||||||
|
function lineColAt(original: string, offset: number): { line: number; col: number } {
|
||||||
|
let line = 1;
|
||||||
|
let col = 1;
|
||||||
|
for (let i = 0; i < offset && i < original.length; i++) {
|
||||||
|
if (original[i] === "\n") {
|
||||||
|
line += 1;
|
||||||
|
col = 1;
|
||||||
|
} else {
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { line, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Safe preview masking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Show ≤4 leading chars, mask the rest. Never reconstructable. */
|
||||||
|
export function maskPreview(span: string): string {
|
||||||
|
const visible = span.slice(0, 4);
|
||||||
|
const masked = span.length > 4 ? "*".repeat(Math.min(span.length - 4, 8)) : "";
|
||||||
|
return `${visible}${masked}${span.length > 12 ? "…" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool-attributed fence detection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOOL_FENCE_INFO = /^```(codex-review|greptile|eval|codex|tool-output)\b/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sorted list of [start, end) offset ranges (in normalized text) that
|
||||||
|
* sit inside a tool-attributed fenced code block. Credential findings inside
|
||||||
|
* these ranges degrade to WARN (unless the doc-example heuristic says the span
|
||||||
|
* is live-format and must still block).
|
||||||
|
*/
|
||||||
|
function toolFenceRanges(normalized: string): Array<[number, number]> {
|
||||||
|
const ranges: Array<[number, number]> = [];
|
||||||
|
const lines = normalized.split("\n");
|
||||||
|
let offset = 0;
|
||||||
|
let inFence = false;
|
||||||
|
let fenceStart = 0;
|
||||||
|
for (const ln of lines) {
|
||||||
|
const isFenceMarker = ln.startsWith("```");
|
||||||
|
if (isFenceMarker) {
|
||||||
|
if (!inFence && TOOL_FENCE_INFO.test(ln)) {
|
||||||
|
inFence = true;
|
||||||
|
fenceStart = offset + ln.length + 1; // content starts after this line
|
||||||
|
} else if (inFence) {
|
||||||
|
ranges.push([fenceStart, offset]); // up to start of closing fence
|
||||||
|
inFence = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += ln.length + 1; // +1 for the \n
|
||||||
|
}
|
||||||
|
if (inFence) ranges.push([fenceStart, normalized.length]); // unterminated → still degrade its own body
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inRanges(offset: number, ranges: Array<[number, number]>): boolean {
|
||||||
|
for (const [s, e] of ranges) if (offset >= s && offset < e) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doc-example heuristic: a credential span inside a tool fence still BLOCKS if
|
||||||
|
* it looks like a LIVE credential (not an obvious placeholder/example). We only
|
||||||
|
* downgrade-to-WARN spans that are clearly illustrative.
|
||||||
|
*/
|
||||||
|
function isObviousDocExample(span: string): boolean {
|
||||||
|
return isPlaceholderSpan(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proximity check ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hasNear(
|
||||||
|
normalized: string,
|
||||||
|
matchStart: number,
|
||||||
|
matchEnd: number,
|
||||||
|
nearRegex: RegExp,
|
||||||
|
window: number,
|
||||||
|
): boolean {
|
||||||
|
const from = Math.max(0, matchStart - window);
|
||||||
|
const to = Math.min(normalized.length, matchEnd + window);
|
||||||
|
const slice = normalized.slice(from, to);
|
||||||
|
const re = new RegExp(nearRegex.source, nearRegex.flags.replace(/g/g, ""));
|
||||||
|
return re.test(slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email allowlist ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function emailAllowed(email: string, opts: ScanOptions): boolean {
|
||||||
|
const lower = email.toLowerCase();
|
||||||
|
if (opts.selfEmail && lower === opts.selfEmail.toLowerCase()) return true;
|
||||||
|
if (opts.repoPublicEmails?.some((e) => e.toLowerCase() === lower)) return true;
|
||||||
|
if (EMAIL_ALLOW_DOMAINS.some((re) => re.test(email))) return true;
|
||||||
|
if (EMAIL_ALLOW_LOCALPARTS.some((re) => re.test(email))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The scan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||||
|
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
|
||||||
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
|
||||||
|
// Fail CLOSED on oversize input. Check byte length BEFORE heavy work.
|
||||||
|
const byteLen = Buffer.byteLength(input, "utf8");
|
||||||
|
if (byteLen > maxBytes) {
|
||||||
|
const finding: Finding = {
|
||||||
|
id: "engine.input_too_large",
|
||||||
|
tier: "HIGH",
|
||||||
|
severity: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: `Input too large to scan safely (${byteLen} > ${maxBytes} bytes) — blocking fail-closed`,
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
preview: "",
|
||||||
|
autoRedactable: false,
|
||||||
|
repoVisibility,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
findings: [finding],
|
||||||
|
counts: { HIGH: 1, MEDIUM: 0, LOW: 0, WARN: 0 },
|
||||||
|
repoVisibility,
|
||||||
|
oversize: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { normalized, map } = normalizeWithMap(input);
|
||||||
|
const fenceRanges = toolFenceRanges(normalized);
|
||||||
|
const allow = new Set(opts.allowlist ?? []);
|
||||||
|
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
// Dedup by (id, original-offset) so overlapping global matches don't double-count.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const pat of PATTERNS) {
|
||||||
|
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(normalized)) !== null) {
|
||||||
|
// Guard against zero-width matches looping forever.
|
||||||
|
if (m.index === re.lastIndex) re.lastIndex++;
|
||||||
|
|
||||||
|
const span = m[1] ?? m[0];
|
||||||
|
const spanStartInMatch = m[1] !== undefined ? m[0].indexOf(m[1]) : 0;
|
||||||
|
const normOffset = m.index + Math.max(0, spanStartInMatch);
|
||||||
|
|
||||||
|
// Per-span placeholder suppression.
|
||||||
|
if (isPlaceholderSpan(span)) continue;
|
||||||
|
if (allow.has(span)) continue;
|
||||||
|
|
||||||
|
// Pattern-specific validators (Luhn, entropy, RFC1918, etc).
|
||||||
|
if (pat.validate && !pat.validate(span, m)) continue;
|
||||||
|
|
||||||
|
// Proximity requirement.
|
||||||
|
if (
|
||||||
|
pat.nearRegex &&
|
||||||
|
!hasNear(normalized, m.index, m.index + m[0].length, pat.nearRegex, pat.nearWindow ?? 100)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email allowlist (layered on top of the pattern).
|
||||||
|
if (pat.id === "pii.email" && emailAllowed(span, opts)) continue;
|
||||||
|
|
||||||
|
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
|
||||||
|
const key = `${pat.id}:${origOffset}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
const { line, col } = lineColAt(input, origOffset);
|
||||||
|
|
||||||
|
// Tool-fence degrade: only credential-category, only obvious doc examples.
|
||||||
|
let severity: Severity = pat.tier;
|
||||||
|
let toolFenceDegraded = false;
|
||||||
|
if (
|
||||||
|
pat.category === "secret" &&
|
||||||
|
inRanges(normOffset, fenceRanges) &&
|
||||||
|
isObviousDocExample(span)
|
||||||
|
) {
|
||||||
|
severity = "WARN";
|
||||||
|
toolFenceDegraded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
id: pat.id,
|
||||||
|
tier: pat.tier,
|
||||||
|
severity,
|
||||||
|
category: pat.category,
|
||||||
|
description: pat.description,
|
||||||
|
line,
|
||||||
|
col,
|
||||||
|
preview: maskPreview(span),
|
||||||
|
autoRedactable: !!pat.autoRedactable,
|
||||||
|
repoVisibility,
|
||||||
|
...(toolFenceDegraded ? { toolFenceDegraded } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable order: by line, then col, then id.
|
||||||
|
findings.sort((a, b) => a.line - b.line || a.col - b.col || a.id.localeCompare(b.id));
|
||||||
|
|
||||||
|
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 };
|
||||||
|
for (const f of findings) counts[f.severity] += 1;
|
||||||
|
|
||||||
|
return { findings, counts, repoVisibility, oversize: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFlags(flags: string): string {
|
||||||
|
let f = flags;
|
||||||
|
if (!f.includes("g")) f += "g";
|
||||||
|
if (!f.includes("m")) f += "m";
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-redaction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RedactResult {
|
||||||
|
body: string;
|
||||||
|
/** ASCII unified-diff preview of the substitutions. */
|
||||||
|
diff: string;
|
||||||
|
/** Findings that could NOT be auto-redacted (structural-corruption guard). */
|
||||||
|
skipped: Finding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute redact tokens for the given finding ids, right-to-left so offsets
|
||||||
|
* stay valid. Refuses to redact a span that sits inside a structural token
|
||||||
|
* (markdown link target, JSON string value) — those fall back to `skipped` so
|
||||||
|
* the skill drops the user to manual edit rather than silently mangling output.
|
||||||
|
*/
|
||||||
|
export function applyRedactions(
|
||||||
|
input: string,
|
||||||
|
findingIds: string[],
|
||||||
|
opts: ScanOptions = {},
|
||||||
|
): RedactResult {
|
||||||
|
const ids = new Set(findingIds);
|
||||||
|
const { findings } = scan(input, opts);
|
||||||
|
const targets = findings
|
||||||
|
.filter((f) => ids.has(f.id) && f.autoRedactable)
|
||||||
|
.map((f) => ({ f, ...locateSpan(input, f) }))
|
||||||
|
.filter((t) => t.start >= 0);
|
||||||
|
|
||||||
|
// Right-to-left so earlier offsets remain valid after splicing.
|
||||||
|
targets.sort((a, b) => b.start - a.start);
|
||||||
|
|
||||||
|
const skipped: Finding[] = [];
|
||||||
|
const diffLines: string[] = [];
|
||||||
|
let body = input;
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
const pat = PATTERNS_BY_ID[t.f.id];
|
||||||
|
const token = pat?.redactToken ?? "<REDACTED>";
|
||||||
|
if (inStructuralToken(body, t.start, t.end)) {
|
||||||
|
skipped.push(t.f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const before = lineContaining(body, t.start);
|
||||||
|
body = body.slice(0, t.start) + token + body.slice(t.end);
|
||||||
|
const after = lineContaining(body, t.start);
|
||||||
|
diffLines.push(`- ${before}`);
|
||||||
|
diffLines.push(`+ ${after}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { body, diff: diffLines.reverse().join("\n"), skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateSpan(input: string, f: Finding): { start: number; end: number } {
|
||||||
|
// Re-derive the offset from line/col on the original text.
|
||||||
|
let offset = 0;
|
||||||
|
let line = 1;
|
||||||
|
while (line < f.line && offset < input.length) {
|
||||||
|
if (input[offset] === "\n") line++;
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
offset += f.col - 1;
|
||||||
|
const pat = PATTERNS_BY_ID[f.id];
|
||||||
|
if (!pat) return { start: -1, end: -1 };
|
||||||
|
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
|
||||||
|
re.lastIndex = Math.max(0, offset - 2);
|
||||||
|
const m = re.exec(input);
|
||||||
|
if (!m) return { start: -1, end: -1 };
|
||||||
|
const span = m[1] ?? m[0];
|
||||||
|
const start = m.index + (m[1] !== undefined ? m[0].indexOf(m[1]) : 0);
|
||||||
|
return { start, end: start + span.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inStructuralToken(body: string, start: number, end: number): boolean {
|
||||||
|
// Markdown link target: [text](...span...). The span may sit anywhere inside
|
||||||
|
// the parenthesized target (e.g. an email embedded in a URL). Walk backward
|
||||||
|
// from the span: if we reach `](` before hitting `)`/whitespace, and forward
|
||||||
|
// we reach `)` before whitespace, the span is inside a link target.
|
||||||
|
for (let i = start - 1; i >= 0; i--) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (ch === ")" || ch === "\n" || ch === " " || ch === "\t") break;
|
||||||
|
if (ch === "(" && i > 0 && body[i - 1] === "]") {
|
||||||
|
for (let j = end; j < body.length; j++) {
|
||||||
|
const c = body[j];
|
||||||
|
if (c === " " || c === "\t" || c === "\n") break;
|
||||||
|
if (c === ")") return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// JSON string value: "key": "...span..." — span is inside a quoted value.
|
||||||
|
const before = body.slice(Math.max(0, start - 80), start);
|
||||||
|
const after = body.slice(end, Math.min(body.length, end + 4));
|
||||||
|
if (/:\s*"$/.test(before) && /^"/.test(after)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineContaining(body: string, offset: number): string {
|
||||||
|
const start = body.lastIndexOf("\n", offset - 1) + 1;
|
||||||
|
let end = body.indexOf("\n", offset);
|
||||||
|
if (end === -1) end = body.length;
|
||||||
|
return body.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exit-code helper for the CLI shim ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 0 clean, 2 MEDIUM present (no HIGH), 3 HIGH present. WARN does not gate. */
|
||||||
|
export function exitCodeFor(result: ScanResult): 0 | 2 | 3 {
|
||||||
|
if (result.counts.HIGH > 0) return 3;
|
||||||
|
if (result.counts.MEDIUM > 0) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,469 @@
|
||||||
|
/**
|
||||||
|
* redact-patterns — the canonical redaction taxonomy.
|
||||||
|
*
|
||||||
|
* Single source of truth shared by `lib/redact-engine.ts`, `bin/gstack-redact`,
|
||||||
|
* `bin/gstack-redact-prepush`, and (via `scripts/resolvers/redact-doc.ts`) the
|
||||||
|
* generated SKILL.md docs for /spec, /ship, /cso, /document-release, and
|
||||||
|
* /document-generate.
|
||||||
|
*
|
||||||
|
* Design notes (locked in /plan-eng-review + two Codex passes):
|
||||||
|
*
|
||||||
|
* - Three tiers. HIGH = genuinely-secret credentials (block). MEDIUM = PII,
|
||||||
|
* legal/damaging, internal-leak, plus credential-shaped patterns that have
|
||||||
|
* high false-positive rates (confirm via AskUserQuestion). LOW = surface only.
|
||||||
|
* - NO wholesale MEDIUM->HIGH promotion on public repos (TENSION-2-followup).
|
||||||
|
* Public repos get sterner per-finding confirmation, not auto-block. The
|
||||||
|
* engine never mutates a finding's tier based on visibility.
|
||||||
|
* - Tier-1 calibration: a gate that cries wolf gets ignored. Stripe
|
||||||
|
* publishable keys, Google AIza keys, JWTs, and env-style KV are MEDIUM, not
|
||||||
|
* HIGH (they are context-variable / high-FP). Only genuinely-secret
|
||||||
|
* credentials block.
|
||||||
|
* - ReDoS safety: every pattern here MUST be linear-time (no nested unbounded
|
||||||
|
* quantifiers). `test/redact-pattern-lint.test.ts` fails CI on a catastrophic
|
||||||
|
* form. The engine also enforces a hard input-size cap that fails CLOSED.
|
||||||
|
* - Placeholder suppression is per-matched-span, not per-line.
|
||||||
|
*
|
||||||
|
* Pattern matching contract: every `regex` is used with the global+multiline
|
||||||
|
* flags the engine applies (`g`, `m`). Capture group 1, when present, is the
|
||||||
|
* "secret span" the engine masks and (for proximity rules) anchors on; when
|
||||||
|
* absent, match[0] is the span.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Tier = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
|
export type Category =
|
||||||
|
| "secret"
|
||||||
|
| "pii"
|
||||||
|
| "legal"
|
||||||
|
| "internal"
|
||||||
|
| "hygiene";
|
||||||
|
|
||||||
|
export interface RedactPattern {
|
||||||
|
/** Stable dotted id, e.g. "aws.access_key". Used in findings + tests. */
|
||||||
|
id: string;
|
||||||
|
tier: Tier;
|
||||||
|
category: Category;
|
||||||
|
/** Human-readable one-liner for the findings table + docs. */
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* The detection regex. Linter-enforced linear-time. The engine adds the
|
||||||
|
* `gm` flags; do not bake `g`/`m` into the source here (keeps `.source`
|
||||||
|
* clean for the docs table and avoids double-global bugs).
|
||||||
|
*/
|
||||||
|
regex: RegExp;
|
||||||
|
/**
|
||||||
|
* Patterns whose redaction is unambiguous enough to offer one-keystroke
|
||||||
|
* auto-redact at MEDIUM tier (email / phone / ssn / cc). The engine wires
|
||||||
|
* the `<REDACTED-*>` replacement token from `redactToken`.
|
||||||
|
*/
|
||||||
|
autoRedactable?: boolean;
|
||||||
|
/** Replacement token for auto-redact, e.g. "<REDACTED-EMAIL>". */
|
||||||
|
redactToken?: string;
|
||||||
|
/**
|
||||||
|
* Extra validators run AFTER the regex matches, ALL must pass for the match
|
||||||
|
* to count. Used for Luhn (credit cards), entropy (env-KV), checksum
|
||||||
|
* (crypto wallets), RFC1918-exclusion (public IPs), etc. Receives the
|
||||||
|
* matched secret span (group 1 or match[0]) and the full match array.
|
||||||
|
*/
|
||||||
|
validate?: (span: string, match: RegExpExecArray) => boolean;
|
||||||
|
/**
|
||||||
|
* Proximity requirement: the pattern only counts if `nearRegex` also matches
|
||||||
|
* within `nearWindow` chars of the match. Used for AWS secret keys (need
|
||||||
|
* `aws_secret_access_key` nearby) and Twilio auth tokens (need an SID nearby).
|
||||||
|
*/
|
||||||
|
nearRegex?: RegExp;
|
||||||
|
nearWindow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validators ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Luhn checksum — credit-card validity. Strips spaces/dashes first. */
|
||||||
|
export function luhnValid(span: string): boolean {
|
||||||
|
const digits = span.replace(/[ \-]/g, "");
|
||||||
|
if (!/^\d{13,19}$/.test(digits)) return false;
|
||||||
|
let sum = 0;
|
||||||
|
let alt = false;
|
||||||
|
for (let i = digits.length - 1; i >= 0; i--) {
|
||||||
|
let d = digits.charCodeAt(i) - 48;
|
||||||
|
if (alt) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) d -= 9;
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
alt = !alt;
|
||||||
|
}
|
||||||
|
return sum % 10 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shannon entropy in bits/char. Used to gate env-style KV (skip placeholders). */
|
||||||
|
export function shannonEntropy(s: string): number {
|
||||||
|
if (!s.length) return 0;
|
||||||
|
const freq: Record<string, number> = {};
|
||||||
|
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
|
||||||
|
let h = 0;
|
||||||
|
for (const ch in freq) {
|
||||||
|
const p = freq[ch] / s.length;
|
||||||
|
h -= p * Math.log2(p);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */
|
||||||
|
export function isPublicIPv4(ip: string): boolean {
|
||||||
|
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (!m) return false;
|
||||||
|
const o = m.slice(1, 5).map(Number);
|
||||||
|
if (o.some((n) => n > 255)) return false;
|
||||||
|
const [a, b] = o;
|
||||||
|
if (a === 10) return false; // 10.0.0.0/8
|
||||||
|
if (a === 127) return false; // loopback
|
||||||
|
if (a === 0) return false; // this-network
|
||||||
|
if (a === 192 && b === 168) return false; // 192.168.0.0/16
|
||||||
|
if (a === 169 && b === 254) return false; // link-local
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT 100.64.0.0/10
|
||||||
|
if (a >= 224) return false; // multicast / reserved
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EIP-55 checksum is out of scope (heavy); we require a length+charset match and
|
||||||
|
// reject all-same-char vanity strings to cut the worst FPs.
|
||||||
|
function looksLikeWallet(span: string): boolean {
|
||||||
|
if (/^0x[a-fA-F0-9]{40}$/.test(span)) {
|
||||||
|
// reject 0x000...0 / 0xfff...f style
|
||||||
|
const body = span.slice(2).toLowerCase();
|
||||||
|
return !/^(.)\1{39}$/.test(body);
|
||||||
|
}
|
||||||
|
// bech32 / base58 — length sanity only
|
||||||
|
return span.length >= 26 && span.length <= 62;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placeholder suppression (per-matched-span, NOT per-line) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A finding is suppressed only if the MATCHED SPAN itself is a placeholder
|
||||||
|
* form — not merely co-located on a line with the word EXAMPLE. This is the
|
||||||
|
* tightened rule from the Codex review (line-based suppression was dangerous).
|
||||||
|
*/
|
||||||
|
// Structural placeholder forms — apply to ANY span (including URLs).
|
||||||
|
const PLACEHOLDER_STRUCTURAL = [
|
||||||
|
/^your[_-]/i,
|
||||||
|
/^<[^>]*>$/, // <REDACTED-FOO>, <your-key>
|
||||||
|
/^\*+$/, // all-asterisks mask
|
||||||
|
/^x{6,}$/i, // xxxxxx mask
|
||||||
|
];
|
||||||
|
|
||||||
|
// Substring placeholder words (example/test/dummy/...). These are NOT applied to
|
||||||
|
// compound spans containing `://` or `@`, because a legit URL/host can contain
|
||||||
|
// "example" (e.g. db.example.com) without being a placeholder secret. AWS docs
|
||||||
|
// keys like AKIAIOSFODNN7EXAMPLE are bare tokens, so the guard still catches them.
|
||||||
|
const PLACEHOLDER_SUBSTRING = [
|
||||||
|
/example/i, // AKIAIOSFODNN7EXAMPLE etc — AWS docs convention
|
||||||
|
/^changeme$/i,
|
||||||
|
/^redacted/i,
|
||||||
|
/^placeholder/i,
|
||||||
|
/^dummy/i,
|
||||||
|
/^fake/i,
|
||||||
|
/test[_-]?(key|token|secret)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isPlaceholderSpan(span: string): boolean {
|
||||||
|
if (PLACEHOLDER_STRUCTURAL.some((re) => re.test(span))) return true;
|
||||||
|
const isCompound = span.includes("://") || span.includes("@");
|
||||||
|
if (!isCompound && PLACEHOLDER_SUBSTRING.some((re) => re.test(span))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The taxonomy ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PATTERNS: RedactPattern[] = [
|
||||||
|
// ===== HIGH — genuinely-secret credentials (block) =====
|
||||||
|
{
|
||||||
|
id: "aws.access_key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "AWS access key ID (AKIA…)",
|
||||||
|
regex: /\b(AKIA[0-9A-Z]{16})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aws.secret_key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "AWS secret access key (with aws_secret_access_key nearby)",
|
||||||
|
regex: /\b([A-Za-z0-9/+=]{40})\b/,
|
||||||
|
nearRegex: /aws.{0,3}secret.{0,3}access.{0,3}key/i,
|
||||||
|
nearWindow: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github.pat",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "GitHub personal access token (classic)",
|
||||||
|
regex: /\b(ghp_[A-Za-z0-9]{36})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github.oauth",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "GitHub OAuth token",
|
||||||
|
regex: /\b(gho_[A-Za-z0-9]{36})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github.server",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "GitHub server-to-server token",
|
||||||
|
regex: /\b(ghs_[A-Za-z0-9]{36})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github.fine_grained",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "GitHub fine-grained PAT",
|
||||||
|
regex: /\b(github_pat_[A-Za-z0-9_]{82})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "anthropic.key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Anthropic API key",
|
||||||
|
regex: /\b(sk-ant-[A-Za-z0-9_\-]{20,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "openai.key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "OpenAI API key (incl. sk-proj-)",
|
||||||
|
regex: /\b(sk-(?:proj-)?[A-Za-z0-9]{32,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sendgrid.key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "SendGrid API key",
|
||||||
|
regex: /\b(SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stripe.secret",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Stripe live SECRET key",
|
||||||
|
regex: /\b(sk_live_[A-Za-z0-9]{24,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack.token",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Slack token (bot/user/app)",
|
||||||
|
regex: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack.webhook",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Slack incoming webhook URL",
|
||||||
|
regex: /(https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{24})/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discord.webhook",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Discord webhook URL",
|
||||||
|
regex: /(https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_\-]{60,})/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "twilio.auth_token",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Twilio auth token (32 hex, with an Account SID nearby)",
|
||||||
|
regex: /\b([a-f0-9]{32})\b/,
|
||||||
|
nearRegex: /\bAC[a-f0-9]{32}\b/,
|
||||||
|
nearWindow: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pem.private_key",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "PEM private key block",
|
||||||
|
regex: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----)/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db.url_with_password",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "Database URL with embedded password",
|
||||||
|
regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||||
|
// Skip when the password segment is itself a placeholder.
|
||||||
|
validate: (span) => {
|
||||||
|
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
||||||
|
const pw = m?.[1] ?? "";
|
||||||
|
return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Z_]+\}?$/.test(pw);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "creds.basic_auth_url",
|
||||||
|
tier: "HIGH",
|
||||||
|
category: "secret",
|
||||||
|
description: "HTTP(S) URL with embedded basic-auth credentials",
|
||||||
|
regex: /(https?:\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/,
|
||||||
|
validate: (span) => {
|
||||||
|
const m = span.match(/:\/\/[^:]+:([^@]+)@/);
|
||||||
|
const pw = m?.[1] ?? "";
|
||||||
|
return !isPlaceholderSpan(pw) && pw !== "" && !/^\$\{?[A-Z_]+\}?$/.test(pw);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== MEDIUM — demoted credential-shaped (high-FP / context-variable) =====
|
||||||
|
{
|
||||||
|
id: "stripe.publishable",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "secret",
|
||||||
|
description: "Stripe live publishable key (often intentionally public)",
|
||||||
|
regex: /\b(pk_live_[A-Za-z0-9]{24,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "google.api_key",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "secret",
|
||||||
|
description: "Google API key (AIza…; sometimes a public client key)",
|
||||||
|
regex: /\b(AIza[0-9A-Za-z\-_]{35})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jwt",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "secret",
|
||||||
|
description: "JSON Web Token (3-segment base64url)",
|
||||||
|
regex: /\b(eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,})\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "env.kv",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "secret",
|
||||||
|
description: "Env-style SECRET assignment with high-entropy value",
|
||||||
|
regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/,
|
||||||
|
// Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs.
|
||||||
|
validate: (span) =>
|
||||||
|
!isPlaceholderSpan(span) &&
|
||||||
|
!/^\$\{?[A-Za-z_]/.test(span) &&
|
||||||
|
shannonEntropy(span) >= 3.0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== MEDIUM — PII (auto-redactable subset) =====
|
||||||
|
{
|
||||||
|
id: "pii.email",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "Email address",
|
||||||
|
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
|
||||||
|
autoRedactable: true,
|
||||||
|
redactToken: "<REDACTED-EMAIL>",
|
||||||
|
// Engine layers the email allowlist (example.com, noreply@, user's own,
|
||||||
|
// repo-public authors) on top of this — see redact-engine.ts.
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pii.phone.e164",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "Phone number (E.164 / common national formats; US/EU-biased)",
|
||||||
|
regex: /(?<![\w.])(\+?[1-9]\d{0,2}[ \-.]?\(?\d{2,4}\)?[ \-.]?\d{3,4}[ \-.]?\d{3,4})(?![\w.])/,
|
||||||
|
autoRedactable: true,
|
||||||
|
redactToken: "<REDACTED-PHONE>",
|
||||||
|
validate: (span) => span.replace(/\D/g, "").length >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pii.ssn",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "US Social Security Number",
|
||||||
|
regex: /\b(\d{3}-\d{2}-\d{4})\b/,
|
||||||
|
autoRedactable: true,
|
||||||
|
redactToken: "<REDACTED-SSN>",
|
||||||
|
// Reject the all-zero-octet placeholders SSNs never use.
|
||||||
|
validate: (span) => {
|
||||||
|
const [a, b, c] = span.split("-");
|
||||||
|
return a !== "000" && b !== "00" && c !== "0000" && a !== "666" && a[0] !== "9";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pii.cc",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "Credit-card number (Luhn-valid)",
|
||||||
|
regex: /\b((?:\d[ \-]?){13,19})\b/,
|
||||||
|
autoRedactable: true,
|
||||||
|
redactToken: "<REDACTED-CC>",
|
||||||
|
validate: (span) => luhnValid(span),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pii.ip_public",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "Public IPv4 address",
|
||||||
|
regex: /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/,
|
||||||
|
validate: (span) => isPublicIPv4(span),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pii.wallet",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "pii",
|
||||||
|
description: "Crypto wallet address (ETH/BTC)",
|
||||||
|
regex: /\b(0x[a-fA-F0-9]{40}|bc1[a-z0-9]{25,39}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})\b/,
|
||||||
|
validate: (span) => looksLikeWallet(span),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== MEDIUM — internal-leak =====
|
||||||
|
{
|
||||||
|
id: "internal.hostname",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "internal",
|
||||||
|
description: "Internal hostname (*.internal/.corp/.local/.prod/.staging)",
|
||||||
|
regex: /\b([a-z0-9][a-z0-9\-]*\.(?:internal|corp|local|lan|prod|staging))\b/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "internal.url_private",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "internal",
|
||||||
|
description: "localhost URL with a non-trivial path",
|
||||||
|
regex: /(https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}\/[^\s)]+)/,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== MEDIUM — legal / damaging =====
|
||||||
|
{
|
||||||
|
id: "legal.nda_marker",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "legal",
|
||||||
|
description: "Confidentiality / NDA marker",
|
||||||
|
regex: /\b(CONFIDENTIAL|UNDER NDA|ATTORNEY[- ]CLIENT|PRIVILEGED|DO NOT DISTRIBUTE|EYES ONLY)\b/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "legal.named_criticism",
|
||||||
|
tier: "MEDIUM",
|
||||||
|
category: "legal",
|
||||||
|
description: "Negative judgment near a capitalized full name (semantic pass is primary)",
|
||||||
|
regex: /\b(incompetent|negligent|fraudulent|fraud|fired|terminated|harassed|underperforming)\b/i,
|
||||||
|
// Require a Capitalized Two-Word name within the window.
|
||||||
|
nearRegex: /\b[A-Z][a-z]+ [A-Z][a-z]+\b/,
|
||||||
|
nearWindow: 80,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== LOW — surface only =====
|
||||||
|
{
|
||||||
|
id: "internal.user_path",
|
||||||
|
tier: "LOW",
|
||||||
|
category: "internal",
|
||||||
|
description: "Absolute path under a user home dir",
|
||||||
|
regex: /(\/(?:Users|home)\/[a-z][a-z0-9_\-]+\/[^\s)]*)/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hygiene.todo",
|
||||||
|
tier: "LOW",
|
||||||
|
category: "hygiene",
|
||||||
|
description: "TODO(owner) marker carried into the artifact",
|
||||||
|
regex: /\b(TODO\([^)]+\))/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lookup by id. */
|
||||||
|
export const PATTERNS_BY_ID: Record<string, RedactPattern> = Object.fromEntries(
|
||||||
|
PATTERNS.map((p) => [p.id, p]),
|
||||||
|
);
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -542,6 +542,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari
|
||||||
aren't present by default, and Liberation Sans is the standard metric-compatible
|
aren't present by default, and Liberation Sans is the standard metric-compatible
|
||||||
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
|
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
|
||||||
|
|
||||||
|
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
|
||||||
|
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
|
||||||
|
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
|
||||||
|
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
|
||||||
|
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
|
||||||
|
without sudo, managed or offline machines).
|
||||||
|
|
||||||
## Core patterns
|
## Core patterns
|
||||||
|
|
||||||
### 80% case — memo/letter
|
### 80% case — memo/letter
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ On Linux, install `fonts-liberation` for correct rendering — Helvetica and Ari
|
||||||
aren't present by default, and Liberation Sans is the standard metric-compatible
|
aren't present by default, and Liberation Sans is the standard metric-compatible
|
||||||
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
|
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
|
||||||
|
|
||||||
|
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
|
||||||
|
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
|
||||||
|
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
|
||||||
|
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
|
||||||
|
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
|
||||||
|
without sudo, managed or offline machines).
|
||||||
|
|
||||||
## Core patterns
|
## Core patterns
|
||||||
|
|
||||||
### 80% case — memo/letter
|
### 80% case — memo/letter
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,34 @@ export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): Pdftotex
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the
|
||||||
|
* emoji render gate. Mirrors resolvePdftotext's resolution order:
|
||||||
|
* 1. $GSTACK_<TOOL>_BIN env override (e.g. GSTACK_PDFFONTS_BIN)
|
||||||
|
* 2. PATH via Bun.which
|
||||||
|
* 3. standard POSIX locations (Homebrew + distro)
|
||||||
|
*
|
||||||
|
* Returns null (does NOT throw) when the tool is missing — the emoji gate skips
|
||||||
|
* cleanly rather than failing on a box without full poppler-utils.
|
||||||
|
*/
|
||||||
|
export function resolvePopplerTool(
|
||||||
|
tool: "pdffonts" | "pdfimages" | "pdftoppm",
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string | null {
|
||||||
|
const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env);
|
||||||
|
if (override) return override;
|
||||||
|
|
||||||
|
const PATH = env.PATH ?? env.Path ?? "";
|
||||||
|
const onPath = Bun.which(tool, { PATH });
|
||||||
|
if (onPath) return onPath;
|
||||||
|
|
||||||
|
for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) {
|
||||||
|
const candidate = findExecutable(path.join(dir, tool));
|
||||||
|
if (candidate) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function isExecutable(p: string): boolean {
|
function isExecutable(p: string): boolean {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(p, fs.constants.X_OK);
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,26 @@
|
||||||
* - No <link>, no external CSS/fonts — everything inlined.
|
* - No <link>, no external CSS/fonts — everything inlined.
|
||||||
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
|
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
|
||||||
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
|
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
|
||||||
|
* - Emoji fallback: the body and @top-center running-header stacks end in an
|
||||||
|
* emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color
|
||||||
|
* Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph
|
||||||
|
* source for emoji code points instead of emitting .notdef tofu (▯). The
|
||||||
|
* @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL"
|
||||||
|
* string, so they get no emoji families. On Linux this requires an
|
||||||
|
* installed color-emoji font — `setup` installs fonts-noto-color-emoji.
|
||||||
|
*
|
||||||
|
* Font stacks are composed from the constants below so each family list has a
|
||||||
|
* single source of truth (DRY) and every stack stays in sync.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
|
||||||
|
// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
|
||||||
|
const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
|
||||||
|
// CJK fallback families, appended to the body stack only.
|
||||||
|
const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`;
|
||||||
|
// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
|
||||||
|
const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
|
||||||
|
|
||||||
export interface PrintCssOptions {
|
export interface PrintCssOptions {
|
||||||
// Document structure
|
// Document structure
|
||||||
cover?: boolean;
|
cover?: boolean;
|
||||||
|
|
@ -84,13 +102,13 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
|
||||||
` size: ${size};`,
|
` size: ${size};`,
|
||||||
` margin: ${margin};`,
|
` margin: ${margin};`,
|
||||||
runningHeader
|
runningHeader
|
||||||
? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
|
? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, sans-serif; font-size: 9pt; color: #666; }`
|
||||||
: ``,
|
: ``,
|
||||||
showPageNumbers
|
showPageNumbers
|
||||||
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
|
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, sans-serif; font-size: 9pt; color: #666; }`
|
||||||
: ``,
|
: ``,
|
||||||
showConfidential
|
showConfidential
|
||||||
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
||||||
: ``,
|
: ``,
|
||||||
`}`,
|
`}`,
|
||||||
``,
|
``,
|
||||||
|
|
@ -107,7 +125,7 @@ function rootTypography(): string {
|
||||||
return [
|
return [
|
||||||
`html { lang: en; }`,
|
`html { lang: en; }`,
|
||||||
`body {`,
|
`body {`,
|
||||||
` font-family: Helvetica, "Liberation Sans", Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`,
|
` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`,
|
||||||
` font-size: 11pt;`,
|
` font-size: 11pt;`,
|
||||||
` line-height: 1.5;`,
|
` line-height: 1.5;`,
|
||||||
` color: #111;`,
|
` color: #111;`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
/**
|
||||||
|
* Emoji render gate — proves emoji code points render as real color glyphs in
|
||||||
|
* the output PDF instead of .notdef tofu boxes (▯). This is the regression gate
|
||||||
|
* for fix/make-pdf-emoji-tofu.
|
||||||
|
*
|
||||||
|
* Why not just check pdftotext? Because text extraction is a FALSE oracle for
|
||||||
|
* emoji: Skia preserves the Unicode in the text cluster even when the displayed
|
||||||
|
* glyph is .notdef, so pdftotext can report the emoji survived on a render that
|
||||||
|
* actually drew tofu. Verified empirically on macOS — pdftotext extracts 😀
|
||||||
|
* regardless of whether a color font was available.
|
||||||
|
*
|
||||||
|
* Two assertions that DO distinguish a real render from tofu:
|
||||||
|
* 1. pdffonts shows an emoji family embedded in the PDF (the cascade selected
|
||||||
|
* a real emoji font — AppleColorEmoji as Type 3 on macOS, NotoColorEmoji
|
||||||
|
* on Linux). Missing-fallback => no emoji font embedded.
|
||||||
|
* 2. pdftoppm rasterizes the page and we count saturated (colored) pixels.
|
||||||
|
* A color-emoji render has hundreds (measured: ~1650 at 100dpi); a tofu
|
||||||
|
* render is a monochrome black outline on white (~0 saturated). Tolerant
|
||||||
|
* threshold, not an exact-pixel fixture diff, to dodge cross-platform AA
|
||||||
|
* and font-version variance.
|
||||||
|
*
|
||||||
|
* Note: pdfimages -list is intentionally NOT used — macOS embeds color emoji as
|
||||||
|
* Type 3 fonts, so pdfimages lists nothing even on a correct render.
|
||||||
|
*
|
||||||
|
* Gating: runs only when the compiled binary + browse + pdffonts + pdftoppm are
|
||||||
|
* available AND a color-emoji font is installed for Chromium to fall back to.
|
||||||
|
* In CI (process.env.CI set) missing prerequisites are a HARD FAILURE, not a
|
||||||
|
* skip — CI is expected to install poppler-utils + fonts-noto-color-emoji, so a
|
||||||
|
* silent skip there would let the tofu regression ship behind a green build.
|
||||||
|
* Local dev without those tools skips cleanly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import { resolvePopplerTool } from "../../src/pdftotext";
|
||||||
|
|
||||||
|
const FIXTURE = path.resolve(__dirname, "../fixtures/emoji-gate.md");
|
||||||
|
const ROOT = path.resolve(__dirname, "../../..");
|
||||||
|
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||||
|
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||||
|
|
||||||
|
// Saturated-pixel floor. Measured ~1650 at 100dpi for the fixture's color
|
||||||
|
// emoji; a tofu render yields ~0. 200 sits well clear of both.
|
||||||
|
const SATURATED_PIXEL_FLOOR = 200;
|
||||||
|
// A pixel is "colored" when its max-min channel spread exceeds this. Black text,
|
||||||
|
// gray rules, and white background all stay near 0; color emoji spike high.
|
||||||
|
const SATURATION_DELTA = 40;
|
||||||
|
// Per-child wall-clock bound. Bun's test timeout doesn't reliably interrupt a
|
||||||
|
// synchronous execFileSync, so each child gets its own ceiling — a wedged
|
||||||
|
// browser/poppler binary (or a hostile GSTACK_*_BIN override) fails instead of
|
||||||
|
// hanging the whole job.
|
||||||
|
const CHILD_TIMEOUT_MS = 25_000;
|
||||||
|
|
||||||
|
/** Is a color-emoji font available for Chromium to fall back to? */
|
||||||
|
function emojiFontAvailable(): boolean {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return fs.existsSync("/System/Library/Fonts/Apple Color Emoji.ttc");
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const fcMatch = Bun.which("fc-match");
|
||||||
|
if (!fcMatch) return false;
|
||||||
|
try {
|
||||||
|
const out = execFileSync(
|
||||||
|
fcMatch,
|
||||||
|
["-f", "%{color}\n", ":lang=und-zsye:charset=1F600"],
|
||||||
|
{ encoding: "utf8", timeout: CHILD_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return /true/i.test(out);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||||
|
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||||
|
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||||
|
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||||
|
if (!resolvePopplerTool("pdffonts")) return { ok: false, reason: "pdffonts not found (install poppler-utils)." };
|
||||||
|
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
|
||||||
|
if (!emojiFontAvailable()) return { ok: false, reason: "no color-emoji font installed; run ./setup (Linux) or install one." };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count pixels in a P6 (binary) PPM whose RGB channel spread exceeds delta.
|
||||||
|
* Validates the header and buffer length so malformed/variant output is a hard
|
||||||
|
* diagnostic (thrown), never a silently-wrong count.
|
||||||
|
*/
|
||||||
|
function countSaturatedPixels(ppmPath: string, delta: number): number {
|
||||||
|
const b = fs.readFileSync(ppmPath);
|
||||||
|
let i = 0;
|
||||||
|
const skipWhitespaceAndComments = () => {
|
||||||
|
for (;;) {
|
||||||
|
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
|
||||||
|
if (b[i] === 0x23) { // '#': comment runs to end of line
|
||||||
|
while (i < b.length && b[i] !== 0x0a) i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const token = (): string => {
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
const s = i;
|
||||||
|
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
|
||||||
|
return b.slice(s, i).toString("ascii");
|
||||||
|
};
|
||||||
|
const magic = token();
|
||||||
|
if (magic !== "P6") throw new Error(`expected P6 PPM, got "${magic}"`);
|
||||||
|
const w = Number(token());
|
||||||
|
const h = Number(token());
|
||||||
|
const maxval = Number(token());
|
||||||
|
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
|
||||||
|
throw new Error(`invalid PPM dimensions: ${w}x${h}`);
|
||||||
|
}
|
||||||
|
if (maxval !== 255) {
|
||||||
|
// pdftoppm emits 8-bit P6 (maxval 255). 16-bit would be 2 bytes/channel and
|
||||||
|
// would break the byte math below — fail loudly rather than miscount.
|
||||||
|
throw new Error(`unexpected PPM maxval ${maxval} (expected 255)`);
|
||||||
|
}
|
||||||
|
i++; // single whitespace byte after maxval precedes the pixel block
|
||||||
|
const total = w * h;
|
||||||
|
if (b.length - i < total * 3) {
|
||||||
|
throw new Error(`PPM pixel buffer too short: have ${b.length - i}, need ${total * 3}`);
|
||||||
|
}
|
||||||
|
let sat = 0;
|
||||||
|
for (let p = 0; p < total; p++) {
|
||||||
|
const o = i + p * 3;
|
||||||
|
const r = b[o], g = b[o + 1], bl = b[o + 2];
|
||||||
|
if (Math.max(r, g, bl) - Math.min(r, g, bl) > delta) sat++;
|
||||||
|
}
|
||||||
|
return sat;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("emoji render gate", () => {
|
||||||
|
const avail = prerequisitesAvailable();
|
||||||
|
|
||||||
|
test.skipIf(!avail.ok)("emoji render as color glyphs, not tofu", () => {
|
||||||
|
if (!avail.ok) return; // type narrowing
|
||||||
|
// Private temp dir under /tmp: browse's validateOutputPath only allows
|
||||||
|
// /tmp and /private/tmp (not os.tmpdir()'s /var/folders), and mkdtemp
|
||||||
|
// dodges the predictable-path symlink/collision risk.
|
||||||
|
const workDir = fs.mkdtempSync("/tmp/make-pdf-emoji-gate-");
|
||||||
|
const outputPdf = path.join(workDir, "out.pdf");
|
||||||
|
const ppmPrefix = path.join(workDir, "page");
|
||||||
|
const ppmPath = `${ppmPrefix}.ppm`;
|
||||||
|
try {
|
||||||
|
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, BROWSE_BIN },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
timeout: CHILD_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
expect(fs.existsSync(outputPdf)).toBe(true);
|
||||||
|
|
||||||
|
// 1. An emoji family must be embedded — the cascade found a real emoji
|
||||||
|
// font instead of falling through to .notdef.
|
||||||
|
const pdffonts = resolvePopplerTool("pdffonts")!;
|
||||||
|
const fontList = execFileSync(pdffonts, [outputPdf], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||||
|
if (!/emoji/i.test(fontList)) {
|
||||||
|
process.stderr.write(`\n--- pdffonts ---\n${fontList}\n--- END ---\n`);
|
||||||
|
}
|
||||||
|
expect(/emoji/i.test(fontList)).toBe(true);
|
||||||
|
|
||||||
|
// 2. The page must actually rasterize to color, not a monochrome tofu box.
|
||||||
|
const pdftoppm = resolvePopplerTool("pdftoppm")!;
|
||||||
|
execFileSync(pdftoppm, ["-r", "100", "-singlefile", outputPdf, ppmPrefix], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
timeout: CHILD_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
expect(fs.existsSync(ppmPath)).toBe(true);
|
||||||
|
const saturated = countSaturatedPixels(ppmPath, SATURATION_DELTA);
|
||||||
|
if (saturated < SATURATED_PIXEL_FLOOR) {
|
||||||
|
process.stderr.write(`\n[emoji-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
|
||||||
|
}
|
||||||
|
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
if (!avail.ok) {
|
||||||
|
// In CI, missing prerequisites are a hard failure — a silent skip would let
|
||||||
|
// the Linux tofu regression ship behind a green build. Locally, just warn.
|
||||||
|
test("emoji gate prerequisites are present (hard-required in CI)", () => {
|
||||||
|
if (process.env.CI) {
|
||||||
|
throw new Error(`emoji gate prerequisites missing in CI: ${avail.reason}`);
|
||||||
|
}
|
||||||
|
console.warn(`[skip] ${avail.reason}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Emoji rendering gate 😀
|
||||||
|
|
||||||
|
This fixture exists to prove that emoji code points render as real color
|
||||||
|
glyphs in the output PDF, not as `.notdef` tofu boxes (▯).
|
||||||
|
|
||||||
|
Color emoji on one line: 😀 ❤️ 🚀 ✅ 💡
|
||||||
|
|
||||||
|
A variation-selector sequence (FE0F) renders color: ❤️ — the bare code point
|
||||||
|
❤ is text-style. Both must come from a font in the cascade, never tofu.
|
||||||
|
|
||||||
|
Non-emoji Unicode (unchanged, regression guard): em dash —, times ×, arrow →,
|
||||||
|
bullet •, ellipsis …
|
||||||
|
|
@ -343,6 +343,46 @@ describe("printCss", () => {
|
||||||
const occurrences = (css.match(/"Liberation Sans"/g) ?? []).length;
|
const occurrences = (css.match(/"Liberation Sans"/g) ?? []).length;
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(4);
|
expect(occurrences).toBeGreaterThanOrEqual(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── emoji fallback (fix/make-pdf-emoji-tofu) ────────────────
|
||||||
|
// Body + @top-center running header get the color-emoji families so
|
||||||
|
// Chromium has a glyph source for emoji code points instead of tofu (▯).
|
||||||
|
// The @bottom-* boxes hold counters / "CONFIDENTIAL" only — no emoji.
|
||||||
|
|
||||||
|
test("body stack includes all three emoji families before sans-serif", () => {
|
||||||
|
const css = printCss();
|
||||||
|
expect(css).toContain(`"Apple Color Emoji"`);
|
||||||
|
expect(css).toContain(`"Segoe UI Emoji"`);
|
||||||
|
expect(css).toContain(`"Noto Color Emoji"`);
|
||||||
|
// Emoji families must precede the generic family so per-character fallback
|
||||||
|
// reaches them before terminating at sans-serif.
|
||||||
|
expect(css).toMatch(/"Noto Color Emoji",\s*sans-serif/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@top-center running header includes emoji families", () => {
|
||||||
|
const css = printCss({ runningHeader: "Q3 Report 🚀" });
|
||||||
|
const topCenter = css.match(/@top-center\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
|
expect(topCenter).toContain(`"Apple Color Emoji"`);
|
||||||
|
expect(topCenter).toContain(`"Noto Color Emoji"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("@bottom-center and @bottom-right do NOT include emoji families", () => {
|
||||||
|
const css = printCss({ confidential: true });
|
||||||
|
const bottomCenter = css.match(/@bottom-center\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
|
const bottomRight = css.match(/@bottom-right\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
|
expect(bottomCenter).not.toContain("Emoji");
|
||||||
|
expect(bottomRight).not.toContain("Emoji");
|
||||||
|
// ...but they still share the sans stack via the SANS_STACK constant.
|
||||||
|
expect(bottomCenter).toContain(`"Liberation Sans"`);
|
||||||
|
expect(bottomRight).toContain(`"Liberation Sans"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emoji families appear in exactly the two emoji-bearing stacks", () => {
|
||||||
|
const css = printCss({ runningHeader: "Title", confidential: true });
|
||||||
|
// body (1) + @top-center (1) = 2 occurrences of the emoji group.
|
||||||
|
const occurrences = (css.match(/"Apple Color Emoji"/g) ?? []).length;
|
||||||
|
expect(occurrences).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── render() — pageNumbers / footerTemplate data flow ───────────────
|
// ─── render() — pageNumbers / footerTemplate data flow ───────────────
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -683,7 +683,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"office-hours","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"office-hours","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -816,6 +820,44 @@ You are a **YC office hours partner**. Your job is to ensure the problem is unde
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Context (preflight)
|
||||||
|
|
||||||
|
Before asking any clarifying questions, load the brain's structured context
|
||||||
|
for this project. The cache layer handles staleness, refresh, and stale-but-
|
||||||
|
usable fallback automatically. Skip questions whose answers are already
|
||||||
|
present in the loaded context; ground recommendations in what the brain
|
||||||
|
already knows about the user, the product, the goals, and recent decisions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf '## Brain Context\n\n'
|
||||||
|
printf '\n### %s\n\n' "product"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get product --project "$SLUG" 2>/dev/null || printf '_(no product digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "goals"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get goals --project "$SLUG" 2>/dev/null || printf '_(no goals digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "user-profile"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get user-profile 2>/dev/null || printf '_(no user-profile digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "recent-decisions"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get recent-decisions --project "$SLUG" 2>/dev/null || printf '_(no recent-decisions digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "salience"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get salience --project "$SLUG" 2>/dev/null || printf '_(no salience digest available yet)_\n'
|
||||||
|
} > /tmp/.gstack-brain-context-$$.md 2>/dev/null
|
||||||
|
[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md
|
||||||
|
rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to use this context:**
|
||||||
|
- If `product` digest names the value prop, target user, or stage — don't re-ask.
|
||||||
|
- If `goals` digest lists active goals — frame recommendations against them.
|
||||||
|
- If `recent-decisions` digest names a prior scope/architecture choice — flag if this plan contradicts.
|
||||||
|
- If `user-profile` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant.
|
||||||
|
- If a digest is `(no X digest available yet)`, treat that section as cold; ask the user.
|
||||||
|
|
||||||
|
**Privacy:** Salience digest is filtered by allowlist (D9 default: `projects/`,
|
||||||
|
`gstack/`, `concepts/` only). Personal/family/therapy content never leaks here.
|
||||||
|
|
||||||
|
|
||||||
## Phase 1: Context Gathering
|
## Phase 1: Context Gathering
|
||||||
|
|
||||||
Understand the project and the area the user wants to change.
|
Understand the project and the area the user wants to change.
|
||||||
|
|
@ -1749,6 +1791,59 @@ Present the reviewed design doc to the user via AskUserQuestion:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Calibration Write-Back (Phase 2 / gated)
|
||||||
|
|
||||||
|
When the skill makes a typed prediction worth tracking (scope decision,
|
||||||
|
TTHW target, architectural bet, wedge commitment), it MAY write a
|
||||||
|
`kind=bet` take to the brain so a calibration profile builds over time.
|
||||||
|
|
||||||
|
**Gated on two things:**
|
||||||
|
1. Brain trust policy for the active endpoint is `personal` (check via
|
||||||
|
`~/.claude/skills/gstack/bin/gstack-config get brain_trust_policy@<endpoint-hash>`).
|
||||||
|
Shared brains skip write-back to avoid polluting team calibration.
|
||||||
|
2. Feature flag `BRAIN_CALIBRATION_WRITEBACK` is set (today: false; flips
|
||||||
|
to true when upstream gbrain v0.42+ ships `takes_add` MCP op).
|
||||||
|
|
||||||
|
When both gates pass, the write-back path uses `mcp__gbrain__takes_add`
|
||||||
|
to record a take with weight 0.9 (per SKILL_CALIBRATION_WEIGHTS).
|
||||||
|
If the MCP op is unavailable, fall back to `mcp__gbrain__put_page` with
|
||||||
|
a gstack:takes fence block (documented but uglier path).
|
||||||
|
|
||||||
|
Mandatory take frontmatter shape:
|
||||||
|
```yaml
|
||||||
|
kind: bet
|
||||||
|
holder: <user identity from whoami>
|
||||||
|
claim: <one-line prediction the skill is making>
|
||||||
|
weight: 0.9
|
||||||
|
since_date: <today's date>
|
||||||
|
expected_resolution: <date in 1-3 months depending on skill>
|
||||||
|
source_skill: office-hours
|
||||||
|
```
|
||||||
|
|
||||||
|
After write, invalidate the affected digests so the next preflight reflects
|
||||||
|
the new state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate product --project "$SLUG" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate goals --project "$SLUG" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate competitive-intel --project "$SLUG" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Cache Background Refresh
|
||||||
|
|
||||||
|
After the skill's work completes (and telemetry has logged), kick a
|
||||||
|
background refresh of any cache digest that's getting close to its TTL.
|
||||||
|
This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||||
|
from the warm cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
(~/.claude/skills/gstack/bin/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Handoff — The Relationship Closing
|
## Phase 6: Handoff — The Relationship Closing
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ You are a **YC office hours partner**. Your job is to ensure the problem is unde
|
||||||
|
|
||||||
{{GBRAIN_CONTEXT_LOAD}}
|
{{GBRAIN_CONTEXT_LOAD}}
|
||||||
|
|
||||||
|
{{BRAIN_PREFLIGHT}}
|
||||||
|
|
||||||
## Phase 1: Context Gathering
|
## Phase 1: Context Gathering
|
||||||
|
|
||||||
Understand the project and the area the user wants to change.
|
Understand the project and the area the user wants to change.
|
||||||
|
|
@ -647,6 +649,10 @@ Present the reviewed design doc to the user via AskUserQuestion:
|
||||||
|
|
||||||
{{GBRAIN_SAVE_RESULTS}}
|
{{GBRAIN_SAVE_RESULTS}}
|
||||||
|
|
||||||
|
{{BRAIN_WRITE_BACK}}
|
||||||
|
|
||||||
|
{{BRAIN_CACHE_REFRESH}}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Handoff — The Relationship Closing
|
## Phase 6: Handoff — The Relationship Closing
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -645,7 +645,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"open-gstack-browser","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"open-gstack-browser","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.51.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",
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dev:make-pdf": "bun run make-pdf/src/cli.ts",
|
"dev:make-pdf": "bun run make-pdf/src/cli.ts",
|
||||||
"dev:design": "bun run design/src/cli.ts",
|
"dev:design": "bun run design/src/cli.ts",
|
||||||
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
|
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
|
||||||
|
"gen:skill-docs:user": "bun run scripts/gen-skill-docs.ts --respect-detection",
|
||||||
"dev": "bun run browse/src/cli.ts",
|
"dev": "bun run browse/src/cli.ts",
|
||||||
"server": "bun run browse/src/server.ts",
|
"server": "bun run browse/src/server.ts",
|
||||||
"test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)",
|
"test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -647,7 +647,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"pair-agent","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"pair-agent","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -677,7 +677,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-ceo-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-ceo-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -1079,6 +1083,42 @@ smarter on their codebase over time.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Context (preflight)
|
||||||
|
|
||||||
|
Before asking any clarifying questions, load the brain's structured context
|
||||||
|
for this project. The cache layer handles staleness, refresh, and stale-but-
|
||||||
|
usable fallback automatically. Skip questions whose answers are already
|
||||||
|
present in the loaded context; ground recommendations in what the brain
|
||||||
|
already knows about the user, the product, the goals, and recent decisions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf '## Brain Context\n\n'
|
||||||
|
printf '\n### %s\n\n' "product"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get product --project "$SLUG" 2>/dev/null || printf '_(no product digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "goals"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get goals --project "$SLUG" 2>/dev/null || printf '_(no goals digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "recent-decisions"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get recent-decisions --project "$SLUG" 2>/dev/null || printf '_(no recent-decisions digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "user-profile"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get user-profile 2>/dev/null || printf '_(no user-profile digest available yet)_\n'
|
||||||
|
} > /tmp/.gstack-brain-context-$$.md 2>/dev/null
|
||||||
|
[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md
|
||||||
|
rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to use this context:**
|
||||||
|
- If `product` digest names the value prop, target user, or stage — don't re-ask.
|
||||||
|
- If `goals` digest lists active goals — frame recommendations against them.
|
||||||
|
- If `recent-decisions` digest names a prior scope/architecture choice — flag if this plan contradicts.
|
||||||
|
- If `user-profile` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant.
|
||||||
|
- If a digest is `(no X digest available yet)`, treat that section as cold; ask the user.
|
||||||
|
|
||||||
|
**Privacy:** Salience digest is filtered by allowlist (D9 default: `projects/`,
|
||||||
|
`gstack/`, `concepts/` only). Personal/family/therapy content never leaks here.
|
||||||
|
|
||||||
|
|
||||||
## Step 0: Nuclear Scope Challenge + Mode Selection
|
## Step 0: Nuclear Scope Challenge + Mode Selection
|
||||||
|
|
||||||
### 0A. Premise Challenge
|
### 0A. Premise Challenge
|
||||||
|
|
@ -2131,6 +2171,59 @@ already knows. A good test: would this insight save time in a future session? If
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Calibration Write-Back (Phase 2 / gated)
|
||||||
|
|
||||||
|
When the skill makes a typed prediction worth tracking (scope decision,
|
||||||
|
TTHW target, architectural bet, wedge commitment), it MAY write a
|
||||||
|
`kind=bet` take to the brain so a calibration profile builds over time.
|
||||||
|
|
||||||
|
**Gated on two things:**
|
||||||
|
1. Brain trust policy for the active endpoint is `personal` (check via
|
||||||
|
`~/.claude/skills/gstack/bin/gstack-config get brain_trust_policy@<endpoint-hash>`).
|
||||||
|
Shared brains skip write-back to avoid polluting team calibration.
|
||||||
|
2. Feature flag `BRAIN_CALIBRATION_WRITEBACK` is set (today: false; flips
|
||||||
|
to true when upstream gbrain v0.42+ ships `takes_add` MCP op).
|
||||||
|
|
||||||
|
When both gates pass, the write-back path uses `mcp__gbrain__takes_add`
|
||||||
|
to record a take with weight 0.8 (per SKILL_CALIBRATION_WEIGHTS).
|
||||||
|
If the MCP op is unavailable, fall back to `mcp__gbrain__put_page` with
|
||||||
|
a gstack:takes fence block (documented but uglier path).
|
||||||
|
|
||||||
|
Mandatory take frontmatter shape:
|
||||||
|
```yaml
|
||||||
|
kind: bet
|
||||||
|
holder: <user identity from whoami>
|
||||||
|
claim: <one-line prediction the skill is making>
|
||||||
|
weight: 0.8
|
||||||
|
since_date: <today's date>
|
||||||
|
expected_resolution: <date in 1-3 months depending on skill>
|
||||||
|
source_skill: plan-ceo-review
|
||||||
|
```
|
||||||
|
|
||||||
|
After write, invalidate the affected digests so the next preflight reflects
|
||||||
|
the new state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate product --project "$SLUG" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate goals --project "$SLUG" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate competitive-intel --project "$SLUG" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Cache Background Refresh
|
||||||
|
|
||||||
|
After the skill's work completes (and telemetry has logged), kick a
|
||||||
|
background refresh of any cache digest that's getting close to its TTL.
|
||||||
|
This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||||
|
from the warm cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
(~/.claude/skills/gstack/bin/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Mode Quick Reference
|
## Mode Quick Reference
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,8 @@ Feed into the Premise Challenge (0A) and Dream State Mapping (0C). If you find a
|
||||||
|
|
||||||
{{GBRAIN_CONTEXT_LOAD}}
|
{{GBRAIN_CONTEXT_LOAD}}
|
||||||
|
|
||||||
|
{{BRAIN_PREFLIGHT}}
|
||||||
|
|
||||||
## Step 0: Nuclear Scope Challenge + Mode Selection
|
## Step 0: Nuclear Scope Challenge + Mode Selection
|
||||||
|
|
||||||
### 0A. Premise Challenge
|
### 0A. Premise Challenge
|
||||||
|
|
@ -854,6 +856,10 @@ If promoted, copy the CEO plan content to `docs/designs/{FEATURE}.md` (create th
|
||||||
|
|
||||||
{{GBRAIN_SAVE_RESULTS}}
|
{{GBRAIN_SAVE_RESULTS}}
|
||||||
|
|
||||||
|
{{BRAIN_WRITE_BACK}}
|
||||||
|
|
||||||
|
{{BRAIN_CACHE_REFRESH}}
|
||||||
|
|
||||||
## Mode Quick Reference
|
## Mode Quick Reference
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -649,7 +649,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-design-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -1009,6 +1013,40 @@ MUST be saved to `~/.gstack/projects/$SLUG/designs/`, NEVER to `.context/`,
|
||||||
`docs/designs/`, `/tmp/`, or any project-local directory. Design artifacts are USER
|
`docs/designs/`, `/tmp/`, or any project-local directory. Design artifacts are USER
|
||||||
data, not project files. They persist across branches, conversations, and workspaces.
|
data, not project files. They persist across branches, conversations, and workspaces.
|
||||||
|
|
||||||
|
## Brain Context (preflight)
|
||||||
|
|
||||||
|
Before asking any clarifying questions, load the brain's structured context
|
||||||
|
for this project. The cache layer handles staleness, refresh, and stale-but-
|
||||||
|
usable fallback automatically. Skip questions whose answers are already
|
||||||
|
present in the loaded context; ground recommendations in what the brain
|
||||||
|
already knows about the user, the product, the goals, and recent decisions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf '## Brain Context\n\n'
|
||||||
|
printf '\n### %s\n\n' "product"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get product --project "$SLUG" 2>/dev/null || printf '_(no product digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "brand"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get brand --project "$SLUG" 2>/dev/null || printf '_(no brand digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "recent-decisions"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get recent-decisions --project "$SLUG" 2>/dev/null || printf '_(no recent-decisions digest available yet)_\n'
|
||||||
|
} > /tmp/.gstack-brain-context-$$.md 2>/dev/null
|
||||||
|
[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md
|
||||||
|
rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to use this context:**
|
||||||
|
- If `product` digest names the value prop, target user, or stage — don't re-ask.
|
||||||
|
- If `goals` digest lists active goals — frame recommendations against them.
|
||||||
|
- If `recent-decisions` digest names a prior scope/architecture choice — flag if this plan contradicts.
|
||||||
|
- If `user-profile` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant.
|
||||||
|
- If a digest is `(no X digest available yet)`, treat that section as cold; ask the user.
|
||||||
|
|
||||||
|
**Privacy:** Salience digest is filtered by allowlist (D9 default: `projects/`,
|
||||||
|
`gstack/`, `concepts/` only). Personal/family/therapy content never leaks here.
|
||||||
|
|
||||||
|
|
||||||
## Step 0: Design Scope Assessment
|
## Step 0: Design Scope Assessment
|
||||||
|
|
||||||
### 0A. Initial Design Rating
|
### 0A. Initial Design Rating
|
||||||
|
|
@ -1871,6 +1909,59 @@ staleness detection: if those files are later deleted, the learning can be flagg
|
||||||
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
||||||
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Calibration Write-Back (Phase 2 / gated)
|
||||||
|
|
||||||
|
When the skill makes a typed prediction worth tracking (scope decision,
|
||||||
|
TTHW target, architectural bet, wedge commitment), it MAY write a
|
||||||
|
`kind=bet` take to the brain so a calibration profile builds over time.
|
||||||
|
|
||||||
|
**Gated on two things:**
|
||||||
|
1. Brain trust policy for the active endpoint is `personal` (check via
|
||||||
|
`~/.claude/skills/gstack/bin/gstack-config get brain_trust_policy@<endpoint-hash>`).
|
||||||
|
Shared brains skip write-back to avoid polluting team calibration.
|
||||||
|
2. Feature flag `BRAIN_CALIBRATION_WRITEBACK` is set (today: false; flips
|
||||||
|
to true when upstream gbrain v0.42+ ships `takes_add` MCP op).
|
||||||
|
|
||||||
|
When both gates pass, the write-back path uses `mcp__gbrain__takes_add`
|
||||||
|
to record a take with weight 0.5 (per SKILL_CALIBRATION_WEIGHTS).
|
||||||
|
If the MCP op is unavailable, fall back to `mcp__gbrain__put_page` with
|
||||||
|
a gstack:takes fence block (documented but uglier path).
|
||||||
|
|
||||||
|
Mandatory take frontmatter shape:
|
||||||
|
```yaml
|
||||||
|
kind: bet
|
||||||
|
holder: <user identity from whoami>
|
||||||
|
claim: <one-line prediction the skill is making>
|
||||||
|
weight: 0.5
|
||||||
|
since_date: <today's date>
|
||||||
|
expected_resolution: <date in 1-3 months depending on skill>
|
||||||
|
source_skill: plan-design-review
|
||||||
|
```
|
||||||
|
|
||||||
|
After write, invalidate the affected digests so the next preflight reflects
|
||||||
|
the new state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate brand --project "$SLUG" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Cache Background Refresh
|
||||||
|
|
||||||
|
After the skill's work completes (and telemetry has logged), kick a
|
||||||
|
background refresh of any cache digest that's getting close to its TTL.
|
||||||
|
This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||||
|
from the warm cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
(~/.claude/skills/gstack/bin/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, recommend the next review(s) based on what this design review discovered. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
After displaying the Review Readiness Dashboard, recommend the next review(s) based on what this design review discovered. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,8 @@ Report findings before proceeding to Step 0.
|
||||||
|
|
||||||
{{DESIGN_SETUP}}
|
{{DESIGN_SETUP}}
|
||||||
|
|
||||||
|
{{BRAIN_PREFLIGHT}}
|
||||||
|
|
||||||
## Step 0: Design Scope Assessment
|
## Step 0: Design Scope Assessment
|
||||||
|
|
||||||
### 0A. Initial Design Rating
|
### 0A. Initial Design Rating
|
||||||
|
|
@ -448,6 +450,12 @@ Substitute values from the Completion Summary:
|
||||||
|
|
||||||
{{LEARNINGS_LOG}}
|
{{LEARNINGS_LOG}}
|
||||||
|
|
||||||
|
{{GBRAIN_SAVE_RESULTS}}
|
||||||
|
|
||||||
|
{{BRAIN_WRITE_BACK}}
|
||||||
|
|
||||||
|
{{BRAIN_CACHE_REFRESH}}
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, recommend the next review(s) based on what this design review discovered. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
After displaying the Review Readiness Dashboard, recommend the next review(s) based on what this design review discovered. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -655,7 +655,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-devex-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-devex-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -1002,6 +1006,42 @@ Note the product type; it influences which persona options are offered in Step 0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Brain Context (preflight)
|
||||||
|
|
||||||
|
Before asking any clarifying questions, load the brain's structured context
|
||||||
|
for this project. The cache layer handles staleness, refresh, and stale-but-
|
||||||
|
usable fallback automatically. Skip questions whose answers are already
|
||||||
|
present in the loaded context; ground recommendations in what the brain
|
||||||
|
already knows about the user, the product, the goals, and recent decisions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf '## Brain Context\n\n'
|
||||||
|
printf '\n### %s\n\n' "product"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get product --project "$SLUG" 2>/dev/null || printf '_(no product digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "developer-persona"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get developer-persona --project "$SLUG" 2>/dev/null || printf '_(no developer-persona digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "recent-decisions"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get recent-decisions --project "$SLUG" 2>/dev/null || printf '_(no recent-decisions digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "competitive-intel"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get competitive-intel --project "$SLUG" 2>/dev/null || printf '_(no competitive-intel digest available yet)_\n'
|
||||||
|
} > /tmp/.gstack-brain-context-$$.md 2>/dev/null
|
||||||
|
[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md
|
||||||
|
rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to use this context:**
|
||||||
|
- If `product` digest names the value prop, target user, or stage — don't re-ask.
|
||||||
|
- If `goals` digest lists active goals — frame recommendations against them.
|
||||||
|
- If `recent-decisions` digest names a prior scope/architecture choice — flag if this plan contradicts.
|
||||||
|
- If `user-profile` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant.
|
||||||
|
- If a digest is `(no X digest available yet)`, treat that section as cold; ask the user.
|
||||||
|
|
||||||
|
**Privacy:** Salience digest is filtered by allowlist (D9 default: `projects/`,
|
||||||
|
`gstack/`, `concepts/` only). Personal/family/therapy content never leaks here.
|
||||||
|
|
||||||
|
|
||||||
## Step 0: DX Investigation (before scoring)
|
## Step 0: DX Investigation (before scoring)
|
||||||
|
|
||||||
The core principle: **gather evidence and force decisions BEFORE scoring, not during
|
The core principle: **gather evidence and force decisions BEFORE scoring, not during
|
||||||
|
|
@ -2049,6 +2089,59 @@ staleness detection: if those files are later deleted, the learning can be flagg
|
||||||
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
**Only log genuine discoveries.** Don't log obvious things. Don't log things the user
|
||||||
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
already knows. A good test: would this insight save time in a future session? If yes, log it.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Calibration Write-Back (Phase 2 / gated)
|
||||||
|
|
||||||
|
When the skill makes a typed prediction worth tracking (scope decision,
|
||||||
|
TTHW target, architectural bet, wedge commitment), it MAY write a
|
||||||
|
`kind=bet` take to the brain so a calibration profile builds over time.
|
||||||
|
|
||||||
|
**Gated on two things:**
|
||||||
|
1. Brain trust policy for the active endpoint is `personal` (check via
|
||||||
|
`~/.claude/skills/gstack/bin/gstack-config get brain_trust_policy@<endpoint-hash>`).
|
||||||
|
Shared brains skip write-back to avoid polluting team calibration.
|
||||||
|
2. Feature flag `BRAIN_CALIBRATION_WRITEBACK` is set (today: false; flips
|
||||||
|
to true when upstream gbrain v0.42+ ships `takes_add` MCP op).
|
||||||
|
|
||||||
|
When both gates pass, the write-back path uses `mcp__gbrain__takes_add`
|
||||||
|
to record a take with weight 0.6 (per SKILL_CALIBRATION_WEIGHTS).
|
||||||
|
If the MCP op is unavailable, fall back to `mcp__gbrain__put_page` with
|
||||||
|
a gstack:takes fence block (documented but uglier path).
|
||||||
|
|
||||||
|
Mandatory take frontmatter shape:
|
||||||
|
```yaml
|
||||||
|
kind: bet
|
||||||
|
holder: <user identity from whoami>
|
||||||
|
claim: <one-line prediction the skill is making>
|
||||||
|
weight: 0.6
|
||||||
|
since_date: <today's date>
|
||||||
|
expected_resolution: <date in 1-3 months depending on skill>
|
||||||
|
source_skill: plan-devex-review
|
||||||
|
```
|
||||||
|
|
||||||
|
After write, invalidate the affected digests so the next preflight reflects
|
||||||
|
the new state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache invalidate developer-persona --project "$SLUG" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Cache Background Refresh
|
||||||
|
|
||||||
|
After the skill's work completes (and telemetry has logged), kick a
|
||||||
|
background refresh of any cache digest that's getting close to its TTL.
|
||||||
|
This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||||
|
from the warm cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
(~/.claude/skills/gstack/bin/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, recommend next reviews:
|
After displaying the Review Readiness Dashboard, recommend next reviews:
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,8 @@ Note the product type; it influences which persona options are offered in Step 0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
{{BRAIN_PREFLIGHT}}
|
||||||
|
|
||||||
## Step 0: DX Investigation (before scoring)
|
## Step 0: DX Investigation (before scoring)
|
||||||
|
|
||||||
The core principle: **gather evidence and force decisions BEFORE scoring, not during
|
The core principle: **gather evidence and force decisions BEFORE scoring, not during
|
||||||
|
|
@ -787,6 +789,12 @@ If any AskUserQuestion goes unanswered, note here. Never silently default.
|
||||||
|
|
||||||
{{LEARNINGS_LOG}}
|
{{LEARNINGS_LOG}}
|
||||||
|
|
||||||
|
{{GBRAIN_SAVE_RESULTS}}
|
||||||
|
|
||||||
|
{{BRAIN_WRITE_BACK}}
|
||||||
|
|
||||||
|
{{BRAIN_CACHE_REFRESH}}
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, recommend next reviews:
|
After displaying the Review Readiness Dashboard, recommend next reviews:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -653,7 +653,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-eng-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-eng-review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -784,6 +788,38 @@ When evaluating architecture, think "boring by default." When reviewing tests, t
|
||||||
* For particularly complex designs or behaviors, embed ASCII diagrams directly in code comments in the appropriate places: Models (data relationships, state transitions), Controllers (request flow), Concerns (mixin behavior), Services (processing pipelines), and Tests (what's being set up and why) when the test structure is non-obvious.
|
* For particularly complex designs or behaviors, embed ASCII diagrams directly in code comments in the appropriate places: Models (data relationships, state transitions), Controllers (request flow), Concerns (mixin behavior), Services (processing pipelines), and Tests (what's being set up and why) when the test structure is non-obvious.
|
||||||
* **Diagram maintenance is part of the change.** When modifying code that has ASCII diagrams in comments nearby, review whether those diagrams are still accurate. Update them as part of the same commit. Stale diagrams are worse than no diagrams — they actively mislead. Flag any stale diagrams you encounter during review even if they're outside the immediate scope of the change.
|
* **Diagram maintenance is part of the change.** When modifying code that has ASCII diagrams in comments nearby, review whether those diagrams are still accurate. Update them as part of the same commit. Stale diagrams are worse than no diagrams — they actively mislead. Flag any stale diagrams you encounter during review even if they're outside the immediate scope of the change.
|
||||||
|
|
||||||
|
## Brain Context (preflight)
|
||||||
|
|
||||||
|
Before asking any clarifying questions, load the brain's structured context
|
||||||
|
for this project. The cache layer handles staleness, refresh, and stale-but-
|
||||||
|
usable fallback automatically. Skip questions whose answers are already
|
||||||
|
present in the loaded context; ground recommendations in what the brain
|
||||||
|
already knows about the user, the product, the goals, and recent decisions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
printf '## Brain Context\n\n'
|
||||||
|
printf '\n### %s\n\n' "product"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get product --project "$SLUG" 2>/dev/null || printf '_(no product digest available yet)_\n'
|
||||||
|
printf '\n### %s\n\n' "recent-decisions"
|
||||||
|
~/.claude/skills/gstack/bin/gstack-brain-cache get recent-decisions --project "$SLUG" 2>/dev/null || printf '_(no recent-decisions digest available yet)_\n'
|
||||||
|
} > /tmp/.gstack-brain-context-$$.md 2>/dev/null
|
||||||
|
[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md
|
||||||
|
rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to use this context:**
|
||||||
|
- If `product` digest names the value prop, target user, or stage — don't re-ask.
|
||||||
|
- If `goals` digest lists active goals — frame recommendations against them.
|
||||||
|
- If `recent-decisions` digest names a prior scope/architecture choice — flag if this plan contradicts.
|
||||||
|
- If `user-profile` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant.
|
||||||
|
- If a digest is `(no X digest available yet)`, treat that section as cold; ask the user.
|
||||||
|
|
||||||
|
**Privacy:** Salience digest is filtered by allowlist (D9 default: `projects/`,
|
||||||
|
`gstack/`, `concepts/` only). Personal/family/therapy content never leaks here.
|
||||||
|
|
||||||
|
|
||||||
## BEFORE YOU START:
|
## BEFORE YOU START:
|
||||||
|
|
||||||
### Design Doc Check
|
### Design Doc Check
|
||||||
|
|
@ -1715,6 +1751,57 @@ already knows. A good test: would this insight save time in a future session? If
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Calibration Write-Back (Phase 2 / gated)
|
||||||
|
|
||||||
|
When the skill makes a typed prediction worth tracking (scope decision,
|
||||||
|
TTHW target, architectural bet, wedge commitment), it MAY write a
|
||||||
|
`kind=bet` take to the brain so a calibration profile builds over time.
|
||||||
|
|
||||||
|
**Gated on two things:**
|
||||||
|
1. Brain trust policy for the active endpoint is `personal` (check via
|
||||||
|
`~/.claude/skills/gstack/bin/gstack-config get brain_trust_policy@<endpoint-hash>`).
|
||||||
|
Shared brains skip write-back to avoid polluting team calibration.
|
||||||
|
2. Feature flag `BRAIN_CALIBRATION_WRITEBACK` is set (today: false; flips
|
||||||
|
to true when upstream gbrain v0.42+ ships `takes_add` MCP op).
|
||||||
|
|
||||||
|
When both gates pass, the write-back path uses `mcp__gbrain__takes_add`
|
||||||
|
to record a take with weight 0.7 (per SKILL_CALIBRATION_WEIGHTS).
|
||||||
|
If the MCP op is unavailable, fall back to `mcp__gbrain__put_page` with
|
||||||
|
a gstack:takes fence block (documented but uglier path).
|
||||||
|
|
||||||
|
Mandatory take frontmatter shape:
|
||||||
|
```yaml
|
||||||
|
kind: bet
|
||||||
|
holder: <user identity from whoami>
|
||||||
|
claim: <one-line prediction the skill is making>
|
||||||
|
weight: 0.7
|
||||||
|
since_date: <today's date>
|
||||||
|
expected_resolution: <date in 1-3 months depending on skill>
|
||||||
|
source_skill: plan-eng-review
|
||||||
|
```
|
||||||
|
|
||||||
|
After write, invalidate the affected digests so the next preflight reflects
|
||||||
|
the new state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
# (no per-skill invalidation targets configured)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Brain Cache Background Refresh
|
||||||
|
|
||||||
|
After the skill's work completes (and telemetry has logged), kick a
|
||||||
|
background refresh of any cache digest that's getting close to its TTL.
|
||||||
|
This is non-blocking — the user doesn't wait. Next invocation benefits
|
||||||
|
from the warm cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||||
|
(~/.claude/skills/gstack/bin/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, check if additional reviews would be valuable. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
After displaying the Review Readiness Dashboard, check if additional reviews would be valuable. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ When evaluating architecture, think "boring by default." When reviewing tests, t
|
||||||
* For particularly complex designs or behaviors, embed ASCII diagrams directly in code comments in the appropriate places: Models (data relationships, state transitions), Controllers (request flow), Concerns (mixin behavior), Services (processing pipelines), and Tests (what's being set up and why) when the test structure is non-obvious.
|
* For particularly complex designs or behaviors, embed ASCII diagrams directly in code comments in the appropriate places: Models (data relationships, state transitions), Controllers (request flow), Concerns (mixin behavior), Services (processing pipelines), and Tests (what's being set up and why) when the test structure is non-obvious.
|
||||||
* **Diagram maintenance is part of the change.** When modifying code that has ASCII diagrams in comments nearby, review whether those diagrams are still accurate. Update them as part of the same commit. Stale diagrams are worse than no diagrams — they actively mislead. Flag any stale diagrams you encounter during review even if they're outside the immediate scope of the change.
|
* **Diagram maintenance is part of the change.** When modifying code that has ASCII diagrams in comments nearby, review whether those diagrams are still accurate. Update them as part of the same commit. Stale diagrams are worse than no diagrams — they actively mislead. Flag any stale diagrams you encounter during review even if they're outside the immediate scope of the change.
|
||||||
|
|
||||||
|
{{BRAIN_PREFLIGHT}}
|
||||||
|
|
||||||
## BEFORE YOU START:
|
## BEFORE YOU START:
|
||||||
|
|
||||||
### Design Doc Check
|
### Design Doc Check
|
||||||
|
|
@ -321,6 +323,10 @@ Substitute values from the Completion Summary:
|
||||||
|
|
||||||
{{GBRAIN_SAVE_RESULTS}}
|
{{GBRAIN_SAVE_RESULTS}}
|
||||||
|
|
||||||
|
{{BRAIN_WRITE_BACK}}
|
||||||
|
|
||||||
|
{{BRAIN_CACHE_REFRESH}}
|
||||||
|
|
||||||
## Next Steps — Review Chaining
|
## Next Steps — Review Chaining
|
||||||
|
|
||||||
After displaying the Review Readiness Dashboard, check if additional reviews would be valuable. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
After displaying the Review Readiness Dashboard, check if additional reviews would be valuable. Read the dashboard output to see which reviews have already been run and whether they are stale.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -658,7 +658,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-tune","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"plan-tune","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
@ -744,50 +748,87 @@ Canonical reference: `docs/designs/PLAN_TUNING_V0.md`.
|
||||||
|
|
||||||
## Step 0: Detect what the user wants
|
## Step 0: Detect what the user wants
|
||||||
|
|
||||||
Read the user's message. Route based on plain-English intent, not keywords:
|
Read the user's message. Route based on plain-English intent, not keywords.
|
||||||
|
|
||||||
1. **First-time use** (config says `question_tuning` is not yet set to `true`) →
|
**Implicit gates run first** (before user-intent routing). These exist so first-time
|
||||||
run `Enable + setup` below.
|
users see the consent prompt, so explicit opt-ins eventually run the 5-Q setup,
|
||||||
2. **"Show my profile" / "what do you know about me" / "show my vibe"** →
|
and so accumulated free-text answers get dream-cycled into actionable proposals.
|
||||||
|
Each gate is guarded by a marker so the user is prompted at most once per choice.
|
||||||
|
|
||||||
|
1. **Consent gate.** If `question_tuning` is `false` AND
|
||||||
|
`~/.gstack/.question-tuning-prompted` is missing → run `Consent + opt-in`
|
||||||
|
below. Honor the answer with a marker write either way; do not re-prompt.
|
||||||
|
2. **Setup gate.** If `question_tuning` is `true` AND
|
||||||
|
`~/.gstack/developer-profile.json`'s `declared` object is empty AND
|
||||||
|
`~/.gstack/.declared-setup-prompted` is missing → run `5-Q setup` below.
|
||||||
|
Touch the marker after setup completes OR is declined.
|
||||||
|
3. **Dream-cycle gate (Layer 8 / cathedral T10/T11).** If
|
||||||
|
`~/.gstack/projects/<slug>/distillation-proposals.json` exists AND has
|
||||||
|
`applied_at` missing on any proposal → run `Dream cycle review` below.
|
||||||
|
Marker: each proposal carries its own `applied_at` so re-firing this
|
||||||
|
gate naturally skips already-handled items.
|
||||||
|
|
||||||
|
When no implicit gate fires, route by user intent:
|
||||||
|
|
||||||
|
4. **"Show my profile" / "what do you know about me" / "show my vibe"** →
|
||||||
run `Inspect profile`.
|
run `Inspect profile`.
|
||||||
3. **"Review questions" / "what have I been asked" / "show recent"** →
|
5. **"Review questions" / "what have I been asked" / "show recent"** →
|
||||||
run `Review question log`.
|
run `Review question log`.
|
||||||
4. **"Stop asking me about X" / "never ask about Y" / "tune: ..."** →
|
6. **"Stop asking me about X" / "never ask about Y" / "tune: ..."** →
|
||||||
run `Set a preference`.
|
run `Set a preference`.
|
||||||
5. **"Update my profile" / "I'm more boil-the-ocean than that" / "I've changed
|
7. **"Update my profile" / "I'm more boil-the-ocean than that" / "I've changed
|
||||||
my mind"** → run `Edit declared profile` (confirm before writing).
|
my mind"** → run `Edit declared profile` (confirm before writing).
|
||||||
6. **"Show the gap" / "how far off is my profile"** → run `Show gap`.
|
8. **"Show the gap" / "how far off is my profile"** → run `Show gap`.
|
||||||
7. **"Turn it off" / "disable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning false`
|
9. **"Dream cycle" / "distill" / "what have I been free-texting"** →
|
||||||
8. **"Turn it on" / "enable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning true`
|
run `Dream cycle distill` below (triggers `gstack-distill-free-text`).
|
||||||
9. **Clear ambiguity** — if you can't tell what the user wants, ask plainly:
|
10. **"Turn it off" / "disable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning false`
|
||||||
|
11. **"Turn it on" / "enable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning true && touch ~/.gstack/.question-tuning-prompted`
|
||||||
|
12. **Clear ambiguity** — if you can't tell what the user wants, ask plainly:
|
||||||
"Do you want to (a) see your profile, (b) review recent questions, (c) set
|
"Do you want to (a) see your profile, (b) review recent questions, (c) set
|
||||||
a preference, (d) update your declared profile, or (e) turn it off?"
|
a preference, (d) update your declared profile, (e) run the dream cycle,
|
||||||
|
or (f) turn it off?"
|
||||||
|
|
||||||
Power-user shortcuts (one-word invocations) — handle these too:
|
Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
`profile`, `vibe`, `gap`, `stats`, `review`, `enable`, `disable`, `setup`.
|
`profile`, `vibe`, `gap`, `stats`, `review`, `enable`, `disable`, `setup`,
|
||||||
|
`distill`, `dream`, `audit`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enable + setup (first-time flow)
|
## Consent + opt-in
|
||||||
|
|
||||||
**When this fires.** The user invokes `/plan-tune` and the preamble shows
|
**When this fires.** Step 0's consent gate: `question_tuning` is `false` AND
|
||||||
`QUESTION_TUNING: false` (the default).
|
`~/.gstack/.question-tuning-prompted` is missing. The user has never been
|
||||||
|
asked.
|
||||||
|
|
||||||
|
**Privacy note.** gstack defaults `question_tuning` to `false` for every user.
|
||||||
|
There is no auto-flip for any cohort. The consent prompt is the only path to
|
||||||
|
enabling, and the answer is honored with a marker file so the user is never
|
||||||
|
re-asked. Contributors are not auto-enrolled (see
|
||||||
|
`docs/designs/PLAN_TUNING_V1.md` §"Decisions log" for the privacy posture
|
||||||
|
rationale). If the user is a contributor (`gstack_contributor: true`), the
|
||||||
|
prompt can mention it as additional context, but the decision is still
|
||||||
|
explicit.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
1. Read the current state:
|
1. Detect contributor state (for prompt framing only, not for auto-action):
|
||||||
```bash
|
```bash
|
||||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||||
|
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || echo "false")
|
||||||
echo "QUESTION_TUNING: $_QT"
|
echo "QUESTION_TUNING: $_QT"
|
||||||
|
echo "CONTRIBUTOR: $_CONTRIB"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. If `false`, use AskUserQuestion:
|
2. AskUserQuestion (use the contributor-specific framing only if `_CONTRIB=true`,
|
||||||
|
otherwise use the general framing):
|
||||||
|
|
||||||
|
**General framing:**
|
||||||
> Question tuning is off. gstack can learn which of its prompts you find
|
> Question tuning is off. gstack can learn which of its prompts you find
|
||||||
> valuable vs noisy — so over time, gstack stops asking questions you've
|
> valuable vs noisy — so over time, gstack stops asking questions you've
|
||||||
> already answered the same way. It takes about 2 minutes to set up your
|
> already answered the same way. It takes about 2 minutes to set up your
|
||||||
> initial profile. v1 is observational: gstack tracks your preferences
|
> initial profile. v1 is observational: gstack tracks your preferences
|
||||||
> and shows you a profile, but doesn't silently change skill behavior yet.
|
> and shows you a profile, but doesn't silently change skill behavior yet.
|
||||||
|
> Logs stay local (`~/.gstack/projects/<slug>/question-log.jsonl`).
|
||||||
>
|
>
|
||||||
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
||||||
>
|
>
|
||||||
|
|
@ -795,13 +836,47 @@ Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
> B) Enable but skip setup (I'll fill it in later)
|
> B) Enable but skip setup (I'll fill it in later)
|
||||||
> C) Cancel — I'm not ready
|
> C) Cancel — I'm not ready
|
||||||
|
|
||||||
3. If A or B: enable:
|
**Contributor framing (only if `_CONTRIB=true`):**
|
||||||
|
> You're a gstack contributor. Question tuning isn't on by default for
|
||||||
|
> anyone, but contributors are the cohort whose data most helps v2 work
|
||||||
|
> (skills adapting to your steering style). Enabling logs every
|
||||||
|
> AskUserQuestion outcome locally to
|
||||||
|
> `~/.gstack/projects/<slug>/question-log.jsonl` — nothing leaves your
|
||||||
|
> machine. v1 is observational only.
|
||||||
|
>
|
||||||
|
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
||||||
|
>
|
||||||
|
> A) Enable + set up (recommended for contributors, ~2 min)
|
||||||
|
> B) Enable but skip setup (I'll fill it in later)
|
||||||
|
> C) Cancel — I'm not ready
|
||||||
|
|
||||||
|
3. ALWAYS touch the marker, regardless of choice:
|
||||||
|
```bash
|
||||||
|
touch ~/.gstack/.question-tuning-prompted
|
||||||
|
```
|
||||||
|
|
||||||
|
4. If A or B: enable:
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-config set question_tuning true
|
~/.claude/skills/gstack/bin/gstack-config set question_tuning true
|
||||||
```
|
```
|
||||||
|
|
||||||
4. If A (full setup), ask FIVE one-per-dimension declaration questions via
|
5. If C: do nothing else. Tell the user: "Question tuning stays off. Re-enable
|
||||||
individual AskUserQuestion calls (one at a time). Use plain English, no jargon:
|
any time with `/plan-tune enable` or `gstack-config set question_tuning true`."
|
||||||
|
|
||||||
|
## 5-Q setup (post-consent, or via Setup gate)
|
||||||
|
|
||||||
|
**When this fires.** Two paths:
|
||||||
|
- Right after the consent prompt above accepts option A.
|
||||||
|
- Standalone via Step 0's setup gate: `question_tuning` is already `true`
|
||||||
|
(user opted in via gstack-config or earlier `/plan-tune enable`) AND
|
||||||
|
`declared` is empty AND `~/.gstack/.declared-setup-prompted` is missing.
|
||||||
|
This catches users who set `question_tuning: true` directly without
|
||||||
|
running the wizard.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Ask FIVE one-per-dimension declaration questions via individual
|
||||||
|
AskUserQuestion calls (one at a time). Use plain English, no jargon:
|
||||||
|
|
||||||
**Q1 — scope_appetite:** "When you're planning a feature, do you lean toward
|
**Q1 — scope_appetite:** "When you're planning a feature, do you lean toward
|
||||||
shipping the smallest useful version fast, or building the complete, edge-
|
shipping the smallest useful version fast, or building the complete, edge-
|
||||||
|
|
@ -854,10 +929,18 @@ Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Tell the user: "Profile set. Question tuning is now on. Use `/plan-tune`
|
2. Touch the marker so the Setup gate doesn't re-fire:
|
||||||
|
```bash
|
||||||
|
touch ~/.gstack/.declared-setup-prompted
|
||||||
|
```
|
||||||
|
Touch it even if the user bails out partway — they were asked; they chose
|
||||||
|
not to complete. The Setup gate respects that. They can rerun the 5-Q
|
||||||
|
anytime with `/plan-tune setup` (Step 0 power-user shortcut).
|
||||||
|
|
||||||
|
3. Tell the user: "Profile set. Question tuning is on. Use `/plan-tune`
|
||||||
again any time to inspect, adjust, or turn it off."
|
again any time to inspect, adjust, or turn it off."
|
||||||
|
|
||||||
6. Show the profile inline as a confirmation (see `Inspect profile` below).
|
4. Show the profile inline as a confirmation (see `Inspect profile` below).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -878,12 +961,18 @@ Parse the JSON. Present in **plain English**, not raw floats:
|
||||||
Format: "**scope_appetite:** 0.8 (boil the ocean — you prefer the complete
|
Format: "**scope_appetite:** 0.8 (boil the ocean — you prefer the complete
|
||||||
version with edge cases covered)"
|
version with edge cases covered)"
|
||||||
|
|
||||||
- If `inferred.diversity` passes the calibration gate (`sample_size >= 20 AND
|
- If `inferred.diversity` passes the **display gate** (`sample_size >= 20 AND
|
||||||
skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`), show
|
skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`), show
|
||||||
the inferred column next to declared:
|
the inferred column next to declared:
|
||||||
"**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)"
|
"**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)"
|
||||||
Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch".
|
Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch".
|
||||||
|
|
||||||
|
This display gate is intentionally lower than the E1 **promotion gate**
|
||||||
|
(90+ days stable across 3+ skills, per `docs/designs/PLAN_TUNING_V0.md`).
|
||||||
|
Displaying inferred values is a UI affordance; shipping behavior-adapting
|
||||||
|
defaults based on the profile is consequential and needs a much higher
|
||||||
|
bar. Do NOT use the display gate as a green light for v2 E1 work.
|
||||||
|
|
||||||
- If the calibration gate isn't met, say: "Not enough observed data yet —
|
- If the calibration gate isn't met, say: "Not enough observed data yet —
|
||||||
need N more events across M more skills before we can show your observed
|
need N more events across M more skills before we can show your observed
|
||||||
profile."
|
profile."
|
||||||
|
|
@ -1031,12 +1120,37 @@ the user decides whether declared is wrong or behavior is wrong.
|
||||||
|
|
||||||
## Stats
|
## Stats
|
||||||
|
|
||||||
|
Cathedral T13 surfaces: host-aware breakdown (claude hook vs codex import
|
||||||
|
vs agent-enriched), marked vs hash-only, auto-decided count, and dream
|
||||||
|
cycle cost-to-date.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-preference --stats
|
~/.claude/skills/gstack/bin/gstack-question-preference --stats
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
[ -f "$_LOG" ] && echo "TOTAL_LOGGED: $(wc -l < "$_LOG" | tr -d ' ')" || echo "TOTAL_LOGGED: 0"
|
if [ -f "$_LOG" ]; then
|
||||||
|
bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const events = [];
|
||||||
|
for (const l of lines) { try { events.push(JSON.parse(l)); } catch {} }
|
||||||
|
const total = events.length;
|
||||||
|
const bySource = {};
|
||||||
|
let marked = 0;
|
||||||
|
for (const e of events) {
|
||||||
|
const src = e.source || 'agent';
|
||||||
|
bySource[src] = (bySource[src] || 0) + 1;
|
||||||
|
if (e.question_id && !e.question_id.startsWith('hook-')) marked++;
|
||||||
|
}
|
||||||
|
console.log('TOTAL_LOGGED: ' + total);
|
||||||
|
console.log('MARKED: ' + marked + ' (' + (total ? Math.round(100*marked/total) : 0) + '%)');
|
||||||
|
for (const s of Object.keys(bySource).sort()) {
|
||||||
|
console.log('SOURCE_' + s.toUpperCase().replace(/-/g,'_') + ': ' + bySource[s]);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo 'TOTAL_LOGGED: 0'
|
||||||
|
fi
|
||||||
~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e "
|
~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e "
|
||||||
const p = JSON.parse(await Bun.stdin.text());
|
const p = JSON.parse(await Bun.stdin.text());
|
||||||
const d = p.inferred?.diversity || {};
|
const d = p.inferred?.diversity || {};
|
||||||
|
|
@ -1045,10 +1159,174 @@ _LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
console.log('DAYS_SPAN: ' + (d.days_span ?? 0));
|
console.log('DAYS_SPAN: ' + (d.days_span ?? 0));
|
||||||
console.log('CALIBRATED: ' + (p.inferred?.sample_size >= 20 && d.skills_covered >= 3 && d.question_ids_covered >= 8 && d.days_span >= 7));
|
console.log('CALIBRATED: ' + (p.inferred?.sample_size >= 20 && d.skills_covered >= 3 && d.question_ids_covered >= 8 && d.days_span >= 7));
|
||||||
"
|
"
|
||||||
|
echo '---DISTILL---'
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text --status
|
||||||
```
|
```
|
||||||
|
|
||||||
Present as a compact summary with plain-English calibration status ("5 more
|
Present as a compact summary with plain-English calibration status ("5 more
|
||||||
events across 2 more skills and you'll be calibrated" or "you're calibrated").
|
events across 2 more skills and you'll be calibrated" or "you're calibrated").
|
||||||
|
Surface the source breakdown so the user can see capture is real (Codex
|
||||||
|
correction — without source columns, the cathedral's "before:0 / after:>0"
|
||||||
|
claim is invisible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent auto-decisions
|
||||||
|
|
||||||
|
Show the last 10 questions where the PreToolUse hook auto-decided (source=
|
||||||
|
`auto-decided` in the log). Lets the user spot-check enforcement and flip
|
||||||
|
any that misfired via `always-ask`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
|
[ ! -f "$_LOG" ] && echo 'NO_LOG' || bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const auto = [];
|
||||||
|
for (const l of lines) {
|
||||||
|
try { const e = JSON.parse(l); if (e.source === 'auto-decided') auto.push(e); } catch {}
|
||||||
|
}
|
||||||
|
const recent = auto.slice(-10).reverse();
|
||||||
|
if (!recent.length) { console.log('(no auto-decisions yet)'); process.exit(0); }
|
||||||
|
for (const r of recent) {
|
||||||
|
console.log(r.ts + ' ' + r.question_id + ' → ' + r.user_choice);
|
||||||
|
console.log(' ' + (r.question_summary || ''));
|
||||||
|
}
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
If any look wrong, offer: "Want to flip `<question_id>` to `always-ask`?"
|
||||||
|
Run `gstack-question-preference --write '{"question_id":"<id>","preference":
|
||||||
|
"always-ask","source":"plan-tune"}'` after Y.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit unmarked questions
|
||||||
|
|
||||||
|
Top N hash-only question_ids by frequency. These are AUQ fires the cathedral
|
||||||
|
hook captured but cannot enforce against (no `<gstack-qid:foo>` marker in
|
||||||
|
the skill template — D18 progressive markers). Surfacing them drives marker
|
||||||
|
adoption: high-traffic unmarked questions are the next candidates to retrofit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
|
[ ! -f "$_LOG" ] && echo 'NO_LOG' || bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const counts = {};
|
||||||
|
const summaries = {};
|
||||||
|
for (const l of lines) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(l);
|
||||||
|
if (e.question_id && e.question_id.startsWith('hook-')) {
|
||||||
|
counts[e.question_id] = (counts[e.question_id] || 0) + 1;
|
||||||
|
summaries[e.question_id] = e.question_summary || '';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const rows = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 10);
|
||||||
|
if (!rows.length) { console.log('(no unmarked questions — coverage is 100%)'); process.exit(0); }
|
||||||
|
for (const [id, n] of rows) {
|
||||||
|
console.log(n + 'x ' + id);
|
||||||
|
console.log(' ' + summaries[id]);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
For each row, suggest where the marker should land (look up the skill from
|
||||||
|
the summary's wording, e.g. "Bundle this fix..." likely lives in
|
||||||
|
`ship/SKILL.md.tmpl`). Don't write markers without user approval — adding
|
||||||
|
markers changes which AUQ fires can be auto-decided, which is a substrate
|
||||||
|
expansion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dream cycle review
|
||||||
|
|
||||||
|
**When this fires.** Step 0's dream-cycle gate: `distillation-proposals.json`
|
||||||
|
has at least one proposal with `applied_at` missing. Or the user explicitly
|
||||||
|
invokes via `/plan-tune distill` / `dream`.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Show the proposals:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --list
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For each unapplied proposal, present it as a numbered item and use
|
||||||
|
AskUserQuestion (one per call, per skill convention). Show:
|
||||||
|
- Kind (`preference` / `declared-nudge` / `memory-nugget`)
|
||||||
|
- Confidence + rationale
|
||||||
|
- The source quotes verbatim (proves user-origin)
|
||||||
|
- What applying does (which file/key/dim changes)
|
||||||
|
|
||||||
|
3. **On accept** (Y): apply via the bin. The skill also publishes the
|
||||||
|
nugget to gbrain when configured.
|
||||||
|
|
||||||
|
For `memory-nugget`:
|
||||||
|
```bash
|
||||||
|
# If gbrain is configured, mirror via MCP first.
|
||||||
|
# (Pseudo — actual gbrain call happens at the agent layer via
|
||||||
|
# mcp__gbrain__put_page; the bin records the published flag.)
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N --gbrain-published true|false
|
||||||
|
```
|
||||||
|
|
||||||
|
For `preference`:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N
|
||||||
|
```
|
||||||
|
|
||||||
|
For `declared-nudge`:
|
||||||
|
```bash
|
||||||
|
# Same bin; updates developer-profile.json declared dim with the
|
||||||
|
# clamped delta.
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **On decline**: skip without marking. User can re-decide later (the
|
||||||
|
proposal stays in the file). To dismiss permanently, manually clear:
|
||||||
|
`gstack-distill-apply --proposal N --dismiss` (not implemented in T11;
|
||||||
|
for now, regenerate via next distill run with corrected free-text).
|
||||||
|
|
||||||
|
5. **gbrain integration.** When `mcp__gbrain__*` tools are available in
|
||||||
|
this session:
|
||||||
|
- On `memory-nugget` apply: `mcp__gbrain__put_page` with the nugget +
|
||||||
|
`mcp__gbrain__extract_facts` + `mcp__gbrain__add_tag` per the cathedral
|
||||||
|
plan D9 routing. Then pass `--gbrain-published true` to the bin so
|
||||||
|
the proposals file records the mirror.
|
||||||
|
- When gbrain isn't configured (no MCP tools), the bin's local file
|
||||||
|
write is the durable source-of-truth and the PreToolUse hook reads it
|
||||||
|
via Layer 8 memory injection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dream cycle distill (manual trigger)
|
||||||
|
|
||||||
|
**When this fires.** The user invokes `/plan-tune distill` / `dream` /
|
||||||
|
`distill` / `dream cycle`. Auto-triggered version lives in Step 0 gate #3.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Run distill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If `RATE_CAPPED`: tell the user "You've hit today's 3 distills/day cap.
|
||||||
|
Run again tomorrow, or `/plan-tune stats` for run history."
|
||||||
|
3. If `NO_FREE_TEXT`: tell the user "No free-text answers since the last
|
||||||
|
distill. Keep using gstack — `Other` responses on AskUserQuestion feed
|
||||||
|
this loop."
|
||||||
|
4. If success: print the proposals count + estimated cost, then route into
|
||||||
|
`Dream cycle review` above for the user to approve each.
|
||||||
|
|
||||||
|
For background mode (e.g., the user wants to keep working):
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text --background
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,50 +52,87 @@ Canonical reference: `docs/designs/PLAN_TUNING_V0.md`.
|
||||||
|
|
||||||
## Step 0: Detect what the user wants
|
## Step 0: Detect what the user wants
|
||||||
|
|
||||||
Read the user's message. Route based on plain-English intent, not keywords:
|
Read the user's message. Route based on plain-English intent, not keywords.
|
||||||
|
|
||||||
1. **First-time use** (config says `question_tuning` is not yet set to `true`) →
|
**Implicit gates run first** (before user-intent routing). These exist so first-time
|
||||||
run `Enable + setup` below.
|
users see the consent prompt, so explicit opt-ins eventually run the 5-Q setup,
|
||||||
2. **"Show my profile" / "what do you know about me" / "show my vibe"** →
|
and so accumulated free-text answers get dream-cycled into actionable proposals.
|
||||||
|
Each gate is guarded by a marker so the user is prompted at most once per choice.
|
||||||
|
|
||||||
|
1. **Consent gate.** If `question_tuning` is `false` AND
|
||||||
|
`~/.gstack/.question-tuning-prompted` is missing → run `Consent + opt-in`
|
||||||
|
below. Honor the answer with a marker write either way; do not re-prompt.
|
||||||
|
2. **Setup gate.** If `question_tuning` is `true` AND
|
||||||
|
`~/.gstack/developer-profile.json`'s `declared` object is empty AND
|
||||||
|
`~/.gstack/.declared-setup-prompted` is missing → run `5-Q setup` below.
|
||||||
|
Touch the marker after setup completes OR is declined.
|
||||||
|
3. **Dream-cycle gate (Layer 8 / cathedral T10/T11).** If
|
||||||
|
`~/.gstack/projects/<slug>/distillation-proposals.json` exists AND has
|
||||||
|
`applied_at` missing on any proposal → run `Dream cycle review` below.
|
||||||
|
Marker: each proposal carries its own `applied_at` so re-firing this
|
||||||
|
gate naturally skips already-handled items.
|
||||||
|
|
||||||
|
When no implicit gate fires, route by user intent:
|
||||||
|
|
||||||
|
4. **"Show my profile" / "what do you know about me" / "show my vibe"** →
|
||||||
run `Inspect profile`.
|
run `Inspect profile`.
|
||||||
3. **"Review questions" / "what have I been asked" / "show recent"** →
|
5. **"Review questions" / "what have I been asked" / "show recent"** →
|
||||||
run `Review question log`.
|
run `Review question log`.
|
||||||
4. **"Stop asking me about X" / "never ask about Y" / "tune: ..."** →
|
6. **"Stop asking me about X" / "never ask about Y" / "tune: ..."** →
|
||||||
run `Set a preference`.
|
run `Set a preference`.
|
||||||
5. **"Update my profile" / "I'm more boil-the-ocean than that" / "I've changed
|
7. **"Update my profile" / "I'm more boil-the-ocean than that" / "I've changed
|
||||||
my mind"** → run `Edit declared profile` (confirm before writing).
|
my mind"** → run `Edit declared profile` (confirm before writing).
|
||||||
6. **"Show the gap" / "how far off is my profile"** → run `Show gap`.
|
8. **"Show the gap" / "how far off is my profile"** → run `Show gap`.
|
||||||
7. **"Turn it off" / "disable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning false`
|
9. **"Dream cycle" / "distill" / "what have I been free-texting"** →
|
||||||
8. **"Turn it on" / "enable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning true`
|
run `Dream cycle distill` below (triggers `gstack-distill-free-text`).
|
||||||
9. **Clear ambiguity** — if you can't tell what the user wants, ask plainly:
|
10. **"Turn it off" / "disable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning false`
|
||||||
|
11. **"Turn it on" / "enable"** → `~/.claude/skills/gstack/bin/gstack-config set question_tuning true && touch ~/.gstack/.question-tuning-prompted`
|
||||||
|
12. **Clear ambiguity** — if you can't tell what the user wants, ask plainly:
|
||||||
"Do you want to (a) see your profile, (b) review recent questions, (c) set
|
"Do you want to (a) see your profile, (b) review recent questions, (c) set
|
||||||
a preference, (d) update your declared profile, or (e) turn it off?"
|
a preference, (d) update your declared profile, (e) run the dream cycle,
|
||||||
|
or (f) turn it off?"
|
||||||
|
|
||||||
Power-user shortcuts (one-word invocations) — handle these too:
|
Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
`profile`, `vibe`, `gap`, `stats`, `review`, `enable`, `disable`, `setup`.
|
`profile`, `vibe`, `gap`, `stats`, `review`, `enable`, `disable`, `setup`,
|
||||||
|
`distill`, `dream`, `audit`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enable + setup (first-time flow)
|
## Consent + opt-in
|
||||||
|
|
||||||
**When this fires.** The user invokes `/plan-tune` and the preamble shows
|
**When this fires.** Step 0's consent gate: `question_tuning` is `false` AND
|
||||||
`QUESTION_TUNING: false` (the default).
|
`~/.gstack/.question-tuning-prompted` is missing. The user has never been
|
||||||
|
asked.
|
||||||
|
|
||||||
|
**Privacy note.** gstack defaults `question_tuning` to `false` for every user.
|
||||||
|
There is no auto-flip for any cohort. The consent prompt is the only path to
|
||||||
|
enabling, and the answer is honored with a marker file so the user is never
|
||||||
|
re-asked. Contributors are not auto-enrolled (see
|
||||||
|
`docs/designs/PLAN_TUNING_V1.md` §"Decisions log" for the privacy posture
|
||||||
|
rationale). If the user is a contributor (`gstack_contributor: true`), the
|
||||||
|
prompt can mention it as additional context, but the decision is still
|
||||||
|
explicit.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
1. Read the current state:
|
1. Detect contributor state (for prompt framing only, not for auto-action):
|
||||||
```bash
|
```bash
|
||||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||||
|
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || echo "false")
|
||||||
echo "QUESTION_TUNING: $_QT"
|
echo "QUESTION_TUNING: $_QT"
|
||||||
|
echo "CONTRIBUTOR: $_CONTRIB"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. If `false`, use AskUserQuestion:
|
2. AskUserQuestion (use the contributor-specific framing only if `_CONTRIB=true`,
|
||||||
|
otherwise use the general framing):
|
||||||
|
|
||||||
|
**General framing:**
|
||||||
> Question tuning is off. gstack can learn which of its prompts you find
|
> Question tuning is off. gstack can learn which of its prompts you find
|
||||||
> valuable vs noisy — so over time, gstack stops asking questions you've
|
> valuable vs noisy — so over time, gstack stops asking questions you've
|
||||||
> already answered the same way. It takes about 2 minutes to set up your
|
> already answered the same way. It takes about 2 minutes to set up your
|
||||||
> initial profile. v1 is observational: gstack tracks your preferences
|
> initial profile. v1 is observational: gstack tracks your preferences
|
||||||
> and shows you a profile, but doesn't silently change skill behavior yet.
|
> and shows you a profile, but doesn't silently change skill behavior yet.
|
||||||
|
> Logs stay local (`~/.gstack/projects/<slug>/question-log.jsonl`).
|
||||||
>
|
>
|
||||||
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
||||||
>
|
>
|
||||||
|
|
@ -103,13 +140,47 @@ Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
> B) Enable but skip setup (I'll fill it in later)
|
> B) Enable but skip setup (I'll fill it in later)
|
||||||
> C) Cancel — I'm not ready
|
> C) Cancel — I'm not ready
|
||||||
|
|
||||||
3. If A or B: enable:
|
**Contributor framing (only if `_CONTRIB=true`):**
|
||||||
|
> You're a gstack contributor. Question tuning isn't on by default for
|
||||||
|
> anyone, but contributors are the cohort whose data most helps v2 work
|
||||||
|
> (skills adapting to your steering style). Enabling logs every
|
||||||
|
> AskUserQuestion outcome locally to
|
||||||
|
> `~/.gstack/projects/<slug>/question-log.jsonl` — nothing leaves your
|
||||||
|
> machine. v1 is observational only.
|
||||||
|
>
|
||||||
|
> RECOMMENDATION: Enable and set up your profile. Completeness: A=9/10.
|
||||||
|
>
|
||||||
|
> A) Enable + set up (recommended for contributors, ~2 min)
|
||||||
|
> B) Enable but skip setup (I'll fill it in later)
|
||||||
|
> C) Cancel — I'm not ready
|
||||||
|
|
||||||
|
3. ALWAYS touch the marker, regardless of choice:
|
||||||
|
```bash
|
||||||
|
touch ~/.gstack/.question-tuning-prompted
|
||||||
|
```
|
||||||
|
|
||||||
|
4. If A or B: enable:
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-config set question_tuning true
|
~/.claude/skills/gstack/bin/gstack-config set question_tuning true
|
||||||
```
|
```
|
||||||
|
|
||||||
4. If A (full setup), ask FIVE one-per-dimension declaration questions via
|
5. If C: do nothing else. Tell the user: "Question tuning stays off. Re-enable
|
||||||
individual AskUserQuestion calls (one at a time). Use plain English, no jargon:
|
any time with `/plan-tune enable` or `gstack-config set question_tuning true`."
|
||||||
|
|
||||||
|
## 5-Q setup (post-consent, or via Setup gate)
|
||||||
|
|
||||||
|
**When this fires.** Two paths:
|
||||||
|
- Right after the consent prompt above accepts option A.
|
||||||
|
- Standalone via Step 0's setup gate: `question_tuning` is already `true`
|
||||||
|
(user opted in via gstack-config or earlier `/plan-tune enable`) AND
|
||||||
|
`declared` is empty AND `~/.gstack/.declared-setup-prompted` is missing.
|
||||||
|
This catches users who set `question_tuning: true` directly without
|
||||||
|
running the wizard.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Ask FIVE one-per-dimension declaration questions via individual
|
||||||
|
AskUserQuestion calls (one at a time). Use plain English, no jargon:
|
||||||
|
|
||||||
**Q1 — scope_appetite:** "When you're planning a feature, do you lean toward
|
**Q1 — scope_appetite:** "When you're planning a feature, do you lean toward
|
||||||
shipping the smallest useful version fast, or building the complete, edge-
|
shipping the smallest useful version fast, or building the complete, edge-
|
||||||
|
|
@ -162,10 +233,18 @@ Power-user shortcuts (one-word invocations) — handle these too:
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Tell the user: "Profile set. Question tuning is now on. Use `/plan-tune`
|
2. Touch the marker so the Setup gate doesn't re-fire:
|
||||||
|
```bash
|
||||||
|
touch ~/.gstack/.declared-setup-prompted
|
||||||
|
```
|
||||||
|
Touch it even if the user bails out partway — they were asked; they chose
|
||||||
|
not to complete. The Setup gate respects that. They can rerun the 5-Q
|
||||||
|
anytime with `/plan-tune setup` (Step 0 power-user shortcut).
|
||||||
|
|
||||||
|
3. Tell the user: "Profile set. Question tuning is on. Use `/plan-tune`
|
||||||
again any time to inspect, adjust, or turn it off."
|
again any time to inspect, adjust, or turn it off."
|
||||||
|
|
||||||
6. Show the profile inline as a confirmation (see `Inspect profile` below).
|
4. Show the profile inline as a confirmation (see `Inspect profile` below).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -186,12 +265,18 @@ Parse the JSON. Present in **plain English**, not raw floats:
|
||||||
Format: "**scope_appetite:** 0.8 (boil the ocean — you prefer the complete
|
Format: "**scope_appetite:** 0.8 (boil the ocean — you prefer the complete
|
||||||
version with edge cases covered)"
|
version with edge cases covered)"
|
||||||
|
|
||||||
- If `inferred.diversity` passes the calibration gate (`sample_size >= 20 AND
|
- If `inferred.diversity` passes the **display gate** (`sample_size >= 20 AND
|
||||||
skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`), show
|
skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`), show
|
||||||
the inferred column next to declared:
|
the inferred column next to declared:
|
||||||
"**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)"
|
"**scope_appetite:** declared 0.8 (boil the ocean) ↔ observed 0.72 (close)"
|
||||||
Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch".
|
Use words for the gap: 0.0-0.1 "close", 0.1-0.3 "drift", 0.3+ "mismatch".
|
||||||
|
|
||||||
|
This display gate is intentionally lower than the E1 **promotion gate**
|
||||||
|
(90+ days stable across 3+ skills, per `docs/designs/PLAN_TUNING_V0.md`).
|
||||||
|
Displaying inferred values is a UI affordance; shipping behavior-adapting
|
||||||
|
defaults based on the profile is consequential and needs a much higher
|
||||||
|
bar. Do NOT use the display gate as a green light for v2 E1 work.
|
||||||
|
|
||||||
- If the calibration gate isn't met, say: "Not enough observed data yet —
|
- If the calibration gate isn't met, say: "Not enough observed data yet —
|
||||||
need N more events across M more skills before we can show your observed
|
need N more events across M more skills before we can show your observed
|
||||||
profile."
|
profile."
|
||||||
|
|
@ -339,12 +424,37 @@ the user decides whether declared is wrong or behavior is wrong.
|
||||||
|
|
||||||
## Stats
|
## Stats
|
||||||
|
|
||||||
|
Cathedral T13 surfaces: host-aware breakdown (claude hook vs codex import
|
||||||
|
vs agent-enriched), marked vs hash-only, auto-decided count, and dream
|
||||||
|
cycle cost-to-date.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-preference --stats
|
~/.claude/skills/gstack/bin/gstack-question-preference --stats
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
[ -f "$_LOG" ] && echo "TOTAL_LOGGED: $(wc -l < "$_LOG" | tr -d ' ')" || echo "TOTAL_LOGGED: 0"
|
if [ -f "$_LOG" ]; then
|
||||||
|
bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const events = [];
|
||||||
|
for (const l of lines) { try { events.push(JSON.parse(l)); } catch {} }
|
||||||
|
const total = events.length;
|
||||||
|
const bySource = {};
|
||||||
|
let marked = 0;
|
||||||
|
for (const e of events) {
|
||||||
|
const src = e.source || 'agent';
|
||||||
|
bySource[src] = (bySource[src] || 0) + 1;
|
||||||
|
if (e.question_id && !e.question_id.startsWith('hook-')) marked++;
|
||||||
|
}
|
||||||
|
console.log('TOTAL_LOGGED: ' + total);
|
||||||
|
console.log('MARKED: ' + marked + ' (' + (total ? Math.round(100*marked/total) : 0) + '%)');
|
||||||
|
for (const s of Object.keys(bySource).sort()) {
|
||||||
|
console.log('SOURCE_' + s.toUpperCase().replace(/-/g,'_') + ': ' + bySource[s]);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo 'TOTAL_LOGGED: 0'
|
||||||
|
fi
|
||||||
~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e "
|
~/.claude/skills/gstack/bin/gstack-developer-profile --profile | bun -e "
|
||||||
const p = JSON.parse(await Bun.stdin.text());
|
const p = JSON.parse(await Bun.stdin.text());
|
||||||
const d = p.inferred?.diversity || {};
|
const d = p.inferred?.diversity || {};
|
||||||
|
|
@ -353,10 +463,174 @@ _LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
console.log('DAYS_SPAN: ' + (d.days_span ?? 0));
|
console.log('DAYS_SPAN: ' + (d.days_span ?? 0));
|
||||||
console.log('CALIBRATED: ' + (p.inferred?.sample_size >= 20 && d.skills_covered >= 3 && d.question_ids_covered >= 8 && d.days_span >= 7));
|
console.log('CALIBRATED: ' + (p.inferred?.sample_size >= 20 && d.skills_covered >= 3 && d.question_ids_covered >= 8 && d.days_span >= 7));
|
||||||
"
|
"
|
||||||
|
echo '---DISTILL---'
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text --status
|
||||||
```
|
```
|
||||||
|
|
||||||
Present as a compact summary with plain-English calibration status ("5 more
|
Present as a compact summary with plain-English calibration status ("5 more
|
||||||
events across 2 more skills and you'll be calibrated" or "you're calibrated").
|
events across 2 more skills and you'll be calibrated" or "you're calibrated").
|
||||||
|
Surface the source breakdown so the user can see capture is real (Codex
|
||||||
|
correction — without source columns, the cathedral's "before:0 / after:>0"
|
||||||
|
claim is invisible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent auto-decisions
|
||||||
|
|
||||||
|
Show the last 10 questions where the PreToolUse hook auto-decided (source=
|
||||||
|
`auto-decided` in the log). Lets the user spot-check enforcement and flip
|
||||||
|
any that misfired via `always-ask`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
|
[ ! -f "$_LOG" ] && echo 'NO_LOG' || bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const auto = [];
|
||||||
|
for (const l of lines) {
|
||||||
|
try { const e = JSON.parse(l); if (e.source === 'auto-decided') auto.push(e); } catch {}
|
||||||
|
}
|
||||||
|
const recent = auto.slice(-10).reverse();
|
||||||
|
if (!recent.length) { console.log('(no auto-decisions yet)'); process.exit(0); }
|
||||||
|
for (const r of recent) {
|
||||||
|
console.log(r.ts + ' ' + r.question_id + ' → ' + r.user_choice);
|
||||||
|
console.log(' ' + (r.question_summary || ''));
|
||||||
|
}
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
If any look wrong, offer: "Want to flip `<question_id>` to `always-ask`?"
|
||||||
|
Run `gstack-question-preference --write '{"question_id":"<id>","preference":
|
||||||
|
"always-ask","source":"plan-tune"}'` after Y.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit unmarked questions
|
||||||
|
|
||||||
|
Top N hash-only question_ids by frequency. These are AUQ fires the cathedral
|
||||||
|
hook captured but cannot enforce against (no `<gstack-qid:foo>` marker in
|
||||||
|
the skill template — D18 progressive markers). Surfacing them drives marker
|
||||||
|
adoption: high-traffic unmarked questions are the next candidates to retrofit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
|
||||||
|
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
|
||||||
|
_LOG="$GSTACK_STATE_ROOT/projects/$SLUG/question-log.jsonl"
|
||||||
|
[ ! -f "$_LOG" ] && echo 'NO_LOG' || bun -e "
|
||||||
|
const lines = require('fs').readFileSync('$_LOG','utf-8').trim().split('\n').filter(Boolean);
|
||||||
|
const counts = {};
|
||||||
|
const summaries = {};
|
||||||
|
for (const l of lines) {
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(l);
|
||||||
|
if (e.question_id && e.question_id.startsWith('hook-')) {
|
||||||
|
counts[e.question_id] = (counts[e.question_id] || 0) + 1;
|
||||||
|
summaries[e.question_id] = e.question_summary || '';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const rows = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 10);
|
||||||
|
if (!rows.length) { console.log('(no unmarked questions — coverage is 100%)'); process.exit(0); }
|
||||||
|
for (const [id, n] of rows) {
|
||||||
|
console.log(n + 'x ' + id);
|
||||||
|
console.log(' ' + summaries[id]);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
For each row, suggest where the marker should land (look up the skill from
|
||||||
|
the summary's wording, e.g. "Bundle this fix..." likely lives in
|
||||||
|
`ship/SKILL.md.tmpl`). Don't write markers without user approval — adding
|
||||||
|
markers changes which AUQ fires can be auto-decided, which is a substrate
|
||||||
|
expansion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dream cycle review
|
||||||
|
|
||||||
|
**When this fires.** Step 0's dream-cycle gate: `distillation-proposals.json`
|
||||||
|
has at least one proposal with `applied_at` missing. Or the user explicitly
|
||||||
|
invokes via `/plan-tune distill` / `dream`.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Show the proposals:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --list
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For each unapplied proposal, present it as a numbered item and use
|
||||||
|
AskUserQuestion (one per call, per skill convention). Show:
|
||||||
|
- Kind (`preference` / `declared-nudge` / `memory-nugget`)
|
||||||
|
- Confidence + rationale
|
||||||
|
- The source quotes verbatim (proves user-origin)
|
||||||
|
- What applying does (which file/key/dim changes)
|
||||||
|
|
||||||
|
3. **On accept** (Y): apply via the bin. The skill also publishes the
|
||||||
|
nugget to gbrain when configured.
|
||||||
|
|
||||||
|
For `memory-nugget`:
|
||||||
|
```bash
|
||||||
|
# If gbrain is configured, mirror via MCP first.
|
||||||
|
# (Pseudo — actual gbrain call happens at the agent layer via
|
||||||
|
# mcp__gbrain__put_page; the bin records the published flag.)
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N --gbrain-published true|false
|
||||||
|
```
|
||||||
|
|
||||||
|
For `preference`:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N
|
||||||
|
```
|
||||||
|
|
||||||
|
For `declared-nudge`:
|
||||||
|
```bash
|
||||||
|
# Same bin; updates developer-profile.json declared dim with the
|
||||||
|
# clamped delta.
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-apply --proposal N
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **On decline**: skip without marking. User can re-decide later (the
|
||||||
|
proposal stays in the file). To dismiss permanently, manually clear:
|
||||||
|
`gstack-distill-apply --proposal N --dismiss` (not implemented in T11;
|
||||||
|
for now, regenerate via next distill run with corrected free-text).
|
||||||
|
|
||||||
|
5. **gbrain integration.** When `mcp__gbrain__*` tools are available in
|
||||||
|
this session:
|
||||||
|
- On `memory-nugget` apply: `mcp__gbrain__put_page` with the nugget +
|
||||||
|
`mcp__gbrain__extract_facts` + `mcp__gbrain__add_tag` per the cathedral
|
||||||
|
plan D9 routing. Then pass `--gbrain-published true` to the bin so
|
||||||
|
the proposals file records the mirror.
|
||||||
|
- When gbrain isn't configured (no MCP tools), the bin's local file
|
||||||
|
write is the durable source-of-truth and the PreToolUse hook reads it
|
||||||
|
via Layer 8 memory injection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dream cycle distill (manual trigger)
|
||||||
|
|
||||||
|
**When this fires.** The user invokes `/plan-tune distill` / `dream` /
|
||||||
|
`distill` / `dream cycle`. Auto-triggered version lives in Step 0 gate #3.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Run distill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If `RATE_CAPPED`: tell the user "You've hit today's 3 distills/day cap.
|
||||||
|
Run again tomorrow, or `/plan-tune stats` for run history."
|
||||||
|
3. If `NO_FREE_TEXT`: tell the user "No free-text answers since the last
|
||||||
|
distill. Keep using gstack — `Other` responses on AskUserQuestion feed
|
||||||
|
this loop."
|
||||||
|
4. If success: print the proposals count + estimated cost, then route into
|
||||||
|
`Dream cycle review` above for the user to approve each.
|
||||||
|
|
||||||
|
For background mode (e.g., the user wants to keep working):
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-distill-free-text --background
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -648,7 +648,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"qa-only","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"qa-only","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
10
qa/SKILL.md
10
qa/SKILL.md
|
|
@ -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)
|
||||||
|
|
@ -654,7 +654,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"qa","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"qa","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -665,7 +665,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"retro","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"retro","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -650,7 +650,11 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
|
||||||
|
|
||||||
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
|
||||||
|
|
||||||
After answer, log best-effort:
|
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
|
||||||
|
|
||||||
|
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses `(recommended)` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two `(recommended)` labels = refuse.
|
||||||
|
|
||||||
|
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
~/.claude/skills/gstack/bin/gstack-question-log '{"skill":"review","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue